Skip to content

feat(web): move product rollouts to Vercel Flags - #894

Merged
GuiBibeau merged 2 commits into
mainfrom
codex/migrate-product-flags-to-vercel
Jul 23, 2026
Merged

feat(web): move product rollouts to Vercel Flags#894
GuiBibeau merged 2 commits into
mainfrom
codex/migrate-product-flags-to-vercel

Conversation

@GuiBibeau

Copy link
Copy Markdown
Collaborator

Summary

  • connect sdp-web to Vercel Flags with environment-scoped SDK keys and the standard Flags discovery endpoint
  • add Vercel-managed organization-onboarding and asset-profiles flags alongside homepage-open-signup, with Clerk user/team context for dashboard targeting
  • replace the frontend Asset Profiles env toggle with server-evaluated Vercel state across issuance pages, chrome, and navigation loading states
  • keep the managed Asset Profiles API capability available while preserving the explicit self-hosted production opt-in
  • remove retired frontend flag plumbing from Docker, Compose, examples, and Turbo env configuration

Vercel configuration applied

Flag Production Preview Development
homepage-open-signup Off On On
organization-onboarding On On On
asset-profiles Off On On
  • configured one server SDK key per Vercel flag environment
  • configured FLAGS for Production, Preview, Development, and the custom dev target (mapped to Preview)
  • removed stale SDP_AUTH_ENTRY_ENABLED, NEXT_PUBLIC_PAYMENTS_RECURRING_ENABLED, and NEXT_PUBLIC_ASSET_PROFILES_ENABLED project variables

The draft Earn branch variable remains in place because PR #831 still consumes it; it should move to a Vercel flag as part of that draft before merge.

Deliberate boundaries

  • PAYMENTS_RECURRING_COLLECTION_ENABLED remains a Cloud Run/self-hosted operational kill switch for the collection cron, not a product rollout flag.
  • custody/compliance/provider availability remains an API-enforced per-organization entitlement, not a Vercel UI flag.
  • self-hosted Asset Profiles keeps ASSET_PROFILES_ENABLED because self-hosted deployments do not depend on Vercel.

Verification

  • pnpm --filter sdp-web build
  • pnpm --filter sdp-web typecheck
  • pnpm --filter sdp-web test:unit (380 tests)
  • pnpm --filter @sdp/api typecheck
  • pnpm --filter @sdp/api lint
  • pnpm check:module-boundaries
  • focused managed/self-hosted API flag assertions via tsx

The full API Vitest suite was not run locally because its global setup requires a container runtime; CI remains the authoritative run.

@vercel

vercel Bot commented Jul 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
sdp-docs Ready Ready Preview, Comment Jul 23, 2026 6:11pm
sdp-web Ready Ready Preview, Comment Jul 23, 2026 6:11pm

Request Review

@greptile-apps

greptile-apps Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR migrates dashboard product rollouts to server-evaluated Vercel Flags.

  • Adds environment-scoped flags for Asset Profiles, organization onboarding, and homepage signup.
  • Supplies Clerk user and organization context for dashboard targeting.
  • Preserves the Asset Profiles opt-in for self-hosted deployments through server runtime configuration.
  • Keeps the managed Asset Profiles API available while moving UI rollout control to Vercel.
  • Removes retired public environment-variable plumbing from Docker, Compose, examples, and Turbo configuration.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains in the fix associated with the previous review thread.

Important Files Changed

Filename Overview
apps/sdp-web/src/flags.ts Defines Vercel-managed rollout flags, Clerk targeting entities, and deployment-aware defaults.
apps/sdp-web/src/lib/feature-flag-defaults.ts Preserves environment-specific defaults, including the explicit self-hosted Asset Profiles opt-in.
apps/sdp-web/src/app/dashboard/layout.tsx Evaluates dashboard flags server-side and passes stable rollout state into shared dashboard chrome.
apps/sdp-web/src/components/dashboard-shell.tsx Uses the server-evaluated Asset Profiles state for issuance chrome and navigation loading states.
apps/sdp-api/src/lib/feature-flags.ts Keeps the managed API capability enabled while retaining the production opt-in for self-hosted deployments.
infra/self-hosted/compose.yml Passes the self-hosted Asset Profiles opt-in to the web server at runtime.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Request[Dashboard request] --> Flags[Vercel Flags evaluation]
  Clerk[Clerk user and organization] --> Flags
  Flags -->|Asset Profiles on| NewUI[Asset Profiles issuance UI]
  Flags -->|Asset Profiles off| LegacyUI[Legacy issuance UI]
  SelfHosted[Self-hosted runtime environment] --> Default[Server-side fallback]
  Default --> Flags
  NewUI --> API[Authenticated Asset Profiles API]
Loading

Reviews (3): Last reviewed commit: "fix(web): preserve self-hosted asset pro..." | Re-trigger Greptile

Comment thread apps/sdp-web/src/flags.ts
@GuiBibeau
GuiBibeau marked this pull request as draft July 23, 2026 18:06
@GuiBibeau

Copy link
Copy Markdown
Collaborator Author

Screenshot

Flag-on homepage state: Vercel-managed homepage-open-signup renders both Try SDP and Contact us.

pr-894-homepage-vercel-flags

@GuiBibeau

Copy link
Copy Markdown
Collaborator Author

@greptileai review

@GuiBibeau

Copy link
Copy Markdown
Collaborator Author

QA-ready.

  • Greptile: 5/5 on e2d528ca with no unresolved review threads
  • CI: all required checks green, including unit and issuance browser E2E
  • Preview: both Vercel deployments green
  • Visual evidence: homepage flag-on state attached above
  • Self-hosted Asset Profiles opt-in preserved server-side

@GuiBibeau
GuiBibeau marked this pull request as ready for review July 23, 2026 18:19
@GuiBibeau
GuiBibeau merged commit 3c578f1 into main Jul 23, 2026
32 checks passed
@GuiBibeau
GuiBibeau deleted the codex/migrate-product-flags-to-vercel branch July 23, 2026 18:59
GuiBibeau added a commit that referenced this pull request Jul 31, 2026
* adds initial feature page

* feat(spc): private channels integration foundation

@sdp/spc Worker-safe client (config/errors/gateway/balances/transfer/auth
+ typed deposit/withdraw/reconcile/streamer skeleton), /v1/private-channels
API routes + service, @sdp/types wire DTOs, env-config, and integration tests.
Type surface verified against SPC source; full doc pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(private-channels): wire API, web, and env config

Rename sdp-spc to @sdp/private-channels; add gateway routes, custody-signed
transfers, feature gate, env fields, and integration tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* remove spc env vars

* refactor(private-channels): remove balance + transfer endpoints

Trim to the connect + health surface. Removes GET /balances and
POST /transfers with their handlers, service functions, and OpenAPI
docs; the package's gateway/balances/transfer modules; the
balance/transfer wire DTOs; the transfer integration test; and the
unused PRIVATE_CHANNEL_AUTH_BASE_URL. /instance, /health, and the web
connect form are unchanged.

* refactor(private-channels): drop health-probe redirect guard, add SSRF TODO

Remove the `redirect: "manual"` guard (and the now-unused redirect passthrough
in http.ts) from the gateway health probe. Leave a TODO to revisit real SSRF
hardening (host allowlist / blocking private IP ranges) before it handles
untrusted input.

* refactor(private-channels): trim scaffolding and wire instance UI

- Inline the health probe into health.ts (drop http.ts); trim PrivateChannelError
  to the used codes; remove the config pass-through seam and the import-smoke test;
  fix stale openapi tag + handler docs
- Consume GET /instance: fetchPrivateChannelInstance + a read-only instance summary
  on the dashboard page
- Add the flag-gated Private Channels sidebar entry + page title
- Add a TODO to the still-disabled Connect button

* add persistance to spc instance

* refactor duplication

* add header tab nav

* refactor(private-channels): drop SSRF notes, guard feature at router middleware

Remove the two SSRF mentions (library probe TODO + handler JSDoc note); the
concern is overblown for the current single-tenant, authenticated probe.

Harmonize feature-flag guarding: apply requirePrivateChannelsFeature once at the
router middleware instead of per-route, and drop the redundant dashboard-flag
short-circuit in testGatewayHealthAction (the page + nav gate is sufficient).

* cleanup /health

* fix title header

* add overview page

* feat: Add instance-scoped logical channels

* feat(private-channels): add verified-wallet persistence and read API

SDP-owned read model for custody wallets verified against a connected
Private Channels instance:

- 0027 migration: verified_wallets table, scoped by org/project/instance,
  unique per (organization_id, project_id, pubkey)
- repository (interface + Postgres) + factory/index wiring
- PrivateChannelVerifiedWalletDto + OpenAPI schemas
- GET /v1/private-channels/wallets

The verify write path (SPC challenge -> sign -> verify) and its auth client
land with the SPC auth subsystem in a follow-up.

* feat: add private channel events

Durable activity trail via PrivateChannelEventService (DB + log sinks).
Emits lifecycle events on connect/disconnect and channel create/archive,
plus error.spc_unreachable from the instance overview. Adds channel- and
project-scoped read APIs with cursor pagination.

* add user maagement

* feat: add Events tab and fetchPrivateChannelEvents function

Introduced a new "Events" tab in the private channels header for better navigation. Added a new function to fetch private channel events with support for query parameters, enhancing the API for project-scoped activity feeds.

* docs(private-channels): add TODOs for user-scoped verified wallets

Note the pending user linkage for verified wallets: FK to
private_channel_users on the table, and reworking the list query to
return a user's verified/unverified wallets. Both gated on PR #7.

* docs(private-channels): scaffold wallet verify/delete TODOs

Add an empty wallets domain module (services/private-channels/wallets.ts)
with TODOs for challenge/verify/delete wallet APIs — verify inserts into
verified_wallets and delete removes from it, each only on a successful SPC
call. Note the single-writer invariant on the repo upsert, and add frontend
TODOs on the overview dashboard for the add/verify and delete wallet UX.

* Use named constants for private channel event type, family, and status.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(private-channels): SPC gateway JSON-RPC client + token balance read

Add `@sdp/private-channels/gateway`: `createChannelGatewayRpc` (an `@sdp/rpc`
Kit client re-pointed at the SPC gateway, with a headers seam reserved for the
future JWT `Authorization`) and `getChannelTokenBalance` (derives the CLASSIC
Token program ATA, treats a missing account as a zero balance). This is the
config-source-agnostic substrate every later flow (balances, transfers,
withdraw settlement) reads through.

Wire the `@sdp/rpc` + classic `@solana-program/token` deps and the `./gateway`
export; add the `PrivateChannelBalance` DTO; add unit tests (fake RPC) and a
slim, app-free live-connectivity suite against the sandbox gateway.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(private-channels): add GET /balance on the persisted instance

Expose `GET /v1/private-channels/balance?owner=&mint=` (feature-gated,
`payments:read`). The handler loads the project's ACTIVE persisted instance
via the instance repository, resolves `owner` from a walletId/pubkey/address,
and reads the balance through the gateway client. `mint` defaults to the
instance cluster's USDC mint (cluster inferred from `chainRpcUrl`).

Balances are per (wallet, mint) and shared across the wallet's logical
channels. The read is intentionally under-gated for now — a TODO documents the
admin/self/co-member visibility gate that lands with the member tables in a
later slice. Includes OpenAPI schema + path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(private-channels): escrow deposit instruction client (@sdp/spc-escrow)

Add a `@solana/kit` client for the SPC escrow program, generated from the
vendored Codama IDL (`scripts/generate.ts`, re-runnable via `pnpm generate`).
The generator repoints the IDL's placeholder program id `GokvZqD2…` to the real
devnet deployment `9tgHa1…` and recomputes the eventAuthority / allowedMint /
ATA PDAs under it, so `getDepositInstructionAsync({payer,user,instance,mint,
amount,recipient})` is turnkey (the "3 devnet gotchas" are handled in codegen).

Generated output is committed; a `kit-augment.ts` shim + `rootOnly` import
strategy keep it compatible with the repo's pinned `@solana/kit` 6.8. Unit tests
assert the 12-account order, discriminator 6, program-id override, and classic
Token program. `biome.json` excludes the generated dir from lint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(private-channels): deposit backend — persistence, API, credit reconciler

Add the escrow deposit flow on the API side:
- Migration 0027 `private_channel_deposits` + repository (status
  prepared→submitted→confirmed→credited→failed; amount + baseline_credited as
  TEXT decimal strings; reconciler + listing indexes).
- `services/private-channels/deposit.ts`: `createChannelDeposit` resolves the
  custody signer (escrow `user`), captures the recipient's baseline channel
  balance, builds the deposit ix via `@sdp/spc-escrow`, and runs the shared
  signing pipeline — but broadcasts to the instance's own chain (`chainRpcUrl`,
  devnet) via `signAsFeePayer` + `sendTransaction`, then confirms inline. Shared
  `mint.ts` (extracted from balance.ts) infers cluster + default USDC mint.
- Routes `POST /deposits`, `GET /deposits`, `GET /deposits/:id` (feature-gated;
  write/read permissions) + `PrivateChannelDeposit` DTO + OpenAPI.
- Credit reconciler `jobs/track-pending-deposits.ts`: submitted→confirmed via
  getSignatureStatuses on the instance chain, confirmed→credited once the gateway
  balance reflects the deposit, plus stale-fail. Wired into the CF `scheduled`
  handler and the Node cron runner, gated on the Private Channels feature flag.

The deposit read/create is intentionally under-gated for now (any project member
with the permission can act for any owner); the admin/self/co-member visibility
gate lands with the member tables in a later slice (TODO in the handler).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(private-channels): deposit page with live progress

Add a Deposit tab + page under Private Channels. The form (custody wallet
picker, amount, optional recipient) submits via a server action; on success it
swaps to a live progress view that polls the deposit every 1.5s and renders a
3-stage stepper — "Sent to devnet escrow" → "Confirmed on devnet" → "Credited in
the channel" — stopping on a terminal state, with a devnet explorer link. Data
helpers added to `lib/private-channels.ts`; the tab is placed above Instance per
the header-tabs convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(private-channels): live devnet deposit integration test

Add an app-free live test that builds the escrow deposit with `@sdp/spc-escrow`,
signs it with a funded devnet keypair (payer + user), broadcasts to devnet,
confirms, and asserts the operator credits the channel (read via the gateway).
Gated behind PRIVATE_CHANNEL_DEPOSIT_SECRET_KEY + PRIVATE_CHANNEL_CHAIN_RPC_URL
(a funded keypair with devnet USDC + SOL); skips otherwise, so CI is unaffected.
This automates the deposit checkpoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(private-channels): deposit self-pays fees (Kora escrow allowlist)

The hosted Kora relay only sponsors transactions that touch allow-listed
programs, and the escrow program 9tgHa1… is not on it, so `signAsFeePayer`
rejected the deposit ("Program 9tgHa1… is not in the allowed list"). Until the
escrow program is added to Kora's allowlist, the custody wallet pays its own fee:
it is now both the escrow `user` and the `payer` / tx fee payer, and the tx is
fully signed by that single signer. A TODO(gasless) documents how to restore the
sponsored fee-payer path once the program is allow-listed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(sdp-api): fire scheduled() cron in local dev

`wrangler dev` doesn't run cron triggers on a timer, so the reconcilers (pending
transfers, private-channel deposits) never fired under `dev:local` and statuses
stalled (e.g. a deposit stuck at `confirmed`, never reaching `credited`).

Add `--test-scheduled` to the wrangler args so `/__scheduled` is exposed, plus a
cron ticker in the dev process that GETs `/__scheduled` every 60s — the same
cadence as the production `crons` trigger — so `scheduled()` runs locally too.
Opt out with SDP_API_DISABLE_CRON_TICKER=1; tune with SDP_API_CRON_TICK_MS.
Dev-loop only; production is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(private-channels): address deposit reconciliation review (3 correctness bugs)

- Transient confirm error no longer fails a broadcast deposit. Once a signature
  exists, a confirmTransaction transport/timeout error leaves the deposit
  `submitted` for the reconciler to finalize; only an on-chain `confirmation.err`
  is terminal. Extracted `confirmAndPersistDeposit` so the semantics are testable.

- Cumulative credit accounting for concurrent deposits. The gateway exposes only
  an aggregate (recipient, mint) balance, so crediting each deposit against its
  own baseline let two concurrent deposits both credit off one balance increase.
  New pure `planDepositCredits` walks a recipient's deposits oldest-first and
  credits a `confirmed` deposit only when the balance covers the cumulative
  threshold through it. Reconciler now groups by (instance, recipient, mint) and
  fetches the full group via the new `listDepositsForRecipient` repo method.

- Reconciler uses the deposit's persisted `instance_id` (new `getById`, incl.
  inactive), not the project's currently-active instance — so a disconnect/
  reconnect can't query the signature/credit against the wrong chain/gateway.

Adds unit tests for all three failure modes: confirmation timeout stays
submitted, concurrent deposits credit only what the balance supports, and the
reconciler loads the persisted instance (not getActiveByProject). 17 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* apply review

* fixes

* fix(private-channels): replace NUL-byte separators in reconciler group key

creditGroupKey used literal \0 bytes as separators, which made git treat the
file as binary and hid it from text search/review tools. Use a visible '|'
(ids are base58/uuid, no collision). Addresses review Low finding.

* feat(private-channels): harden deposit persistence + emit activity events

Addresses the deposit-reconciliation review + integrates the merged event model:
- Renumber migration 0027 -> 0029 (dev merged events at 0028).
- Deposits are financial records: drop the instance FK so a deposit SURVIVES
  instance deletion (like private_channel_events); the delete handler now rejects
  deleting an instance while non-terminal deposits exist (countNonTerminalByInstance).
- Snapshot the reconciliation context (gateway_url/chain_rpc_url/escrow_*) on each
  deposit row; the service populates it and the reconciler uses the SNAPSHOT — not
  the instance's current (possibly-reconnected/mutated) config — so a reconnect
  can't move the chain/gateway a pending deposit is reconciled against.
- Compare-and-swap status transitions (updateDeposit `expectedStatus`) so concurrent
  workers can't regress/overwrite state.
- Emit transfer.deposit.submitted (on broadcast) and transfer.deposit.credited (on
  credit) into private_channel_events via the runtime event service (shared
  deposit-events.ts, usable from the request path and the cron reconciler).

Unit tests updated: reconciler now asserts snapshot-config use + CAS + event emit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(private-channels): gate deposit + balance routes to project admins (interim)

The merged user-mgmt work added members/verified-wallets, but the deposit and
balance routes still act on financial data / custody wallets independent of that
model — any project member with payments:* could deposit to arbitrary addresses or
read arbitrary SPC balances. Until wallet verification lands and we can gate to
"own verified wallet / channels you belong to", require `projects:admin` IN
ADDITION to payments:* on POST/GET /deposits and GET /balance. Interim; revisit
when the verification slice lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(private-channels): SPC wallet verify/delete write path + review polish

Complete PR #4's TODO list — land the verify/delete write path and dashboard
UX for verifying custody wallets against a connected SPC instance:

- SPC auth client (packages/sdp-private-channels): one typed transport for the
  auth service (register/login + challenge/verify/delete wallet), injectable
  fetch for tests
- wallets domain module: single writer of private_channel_verified_wallets;
  verify = challenge -> sign (any custody provider) -> SPC verify -> upsert;
  delete = SPC delete -> remove mirror row
- migrations 0029 (per-member user_id FK + re-scoped uniqueness) and 0030
  (drop the now-redundant private_channel_users.wallet_verified)
- dashboard verify/revoke UX; per-member GET /wallets

Review polish:
- idempotent verify (SPC 409 "already verified" -> treat as success + upsert)
  and convergent delete (SPC 400 "not associated" -> still remove mirror row)
- unknown/foreign walletId -> 404 (not 500); resolve signer before the SPC
  challenge so an invalid wallet fails without minting a nonce
- unify the SPC auth transport; share extractSdpApiErrorMessage; drop dead code
- frontend: load-error state, per-row button disable, drop redundant router.refresh
- revert accidental docs regeneration; remove broken seed:clerk-users script
- tests for the auth client and the wallet write path

SPC user-auth JWT caching is deferred to a follow-up PR (today we log in on
every SPC call; TODO noted at the login site in wallets.ts).

* chore(private-channels): drop stale docs revert

An earlier revert of llms.txt/llms-full.txt/next-env.d.ts used a stale local
dev, which showed up as the branch reverting dev's canonical-URL update and
Next.js types path. Sync these three files to origin/dev so they leave the PR
diff entirely — this branch shouldn't touch docs.

* refactor(private-channels): tighten SPC delete convergence, drop dead code

Review follow-ups on the verified-wallet write path:

- deletePrivateChannelWallet: converge only on SPC's 400 ("wallet not
  associated with this user"). SPC's delete returns 400 for both an
  already-unlinked and a nonexistent pubkey and never returns 404, so the
  NOT_FOUND branch was dead. Real failures (401/503/500) still surface.
- Remove the no-op assertApiKeyWalletAccess call (+ its import). API-key auth
  can never reach this path (getAuth hardcodes userId=null for api_key, and
  resolveWalletSession forbids callers without a userId); org/project scoping
  via createOrgSigner remains the authorization boundary.
- Remove the unused PrivateChannelVerifiedWalletRepositoryContext type and its
  orphaned RepositoryDbClient import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(private-channels): instance-scope verified wallets + squash migration baseline

Address PR #4 review (threads 2 + 6):

- Scope verified wallets to the connected SPC instance so a verification never transfers across instances. Uniqueness is now (user_id, instance_id, pubkey) — a member may verify many wallets per instance; the same pubkey may recur under another instance or member.

- Squash the private-channels migration baseline: fold the verified-wallet user_id link into 0027, drop the wallet_verified column at creation, and remove the obsolete 0029/0030 migrations (greenfield/unshipped — reapply locally).

- Repo: upsert ON CONFLICT (user_id, instance_id, pubkey); listByUserAndInstance; deleteByUserInstanceAndPubkey. Service list/verify/delete resolve and scope to the active instance.

Deferred: instance-scoping the members' verified_wallet_count.

* feat(private-channels): instance-scope members' verified wallet count

Address PR #4 review (thread 2 follow-up): the members' verified_wallet_count counted a member's verifications regardless of instance, so a stale/deactivated instance's wallets leaked into the count.

Scope the count subquery to the project's active instance (0 when none is connected), matching the instance-scoped read/write path. DTO shape (verifiedWalletCount: number), handler, and members table are unchanged.

* feat(private-channels): pass the SPC JWT to the gateway client

The gateway JWT-gates private reads (token balances). PR #4 added the machinery to
mint an SPC token — `getSpcSession` logs in as the acting member's SPC user against
the instance's AUTH service (`authUrl`, a different service from `gatewayUrl`) — but
nothing wired it into the gateway transport. This does that for the request paths.

- New `services/private-channels/auth/gateway-auth.ts`:
  `resolveGatewayAuthToken` returns the bearer token for gateway reads, or
  `undefined` when the instance has auth DISABLED (open deployments keep working).
  When auth IS enabled it fails with a clear FORBIDDEN if the caller has no user
  identity (API-key auth can't mint a session) or isn't an invited member — better
  than an opaque gateway 401. `gatewayAuthOptions` builds the header object.
- `getChannelBalance` takes an optional `authToken` and passes
  `{ Authorization: Bearer … }` through `createChannelGatewayRpc` (the header seam
  that was already there).
- `GET /balance` and `POST /deposits` (its gateway baseline read) resolve the
  caller's token and pass it down.

NOT covered — the cron credit reconciler still reads the gateway unauthenticated
and will 401 on an auth-enabled instance, stalling credit detection at `confirmed`.
It has no user identity to log in as; resolving that needs a deliberate choice of
SPC identity (map the recipient pubkey back to its member — the verified-wallet
repo has no by-pubkey lookup yet — or a service/operator SPC user). Documented as a
TODO at the call site and tracked with the settlement-ledger rework.

Note the load-bearing assumption this makes testable: that a token minted by the
SPC AUTH service is accepted by the GATEWAY. `GET /v1/private-channels/balance` is
the cheapest probe.

Verified: api typecheck + biome clean; 27 service/job node tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(private-channels): authenticate the credit reconciler via the recipient's SPC identity

Closes the last gap blocking `confirmed -> credited` on an auth-enabled gateway. The
cron had no request user, so its balance read went out unauthenticated and the
gateway answered 401, stalling every deposit at `confirmed`.

The identity is derived from the data: the credit recipient's pubkey must have been
VERIFIED by a member, mapping `(instance_id, pubkey) ->
private_channel_verified_wallets.user_id -> private_channel_users` — the member whose
stored SPC credential can mint a token. Reading a member's own balance under their own
SPC identity is the natural attribution.

Adapted to dev's post-review model (PR #4): verifications are now INSTANCE-scoped
(uniqueness `user_id + instance_id + pubkey`), so the reverse lookup is
`findByInstanceAndPubkey(instanceId, pubkey)` rather than org/project-scoped. That's
also the right grain — a verification doesn't transfer across instances, and a job
always knows the deposit's instance.

- `resolveOwnerGatewayAuth` returns a discriminated result: `open` (auth off),
  `token`, or `unavailable` with a reason. It NEVER throws — an unresolvable identity
  must not fail the whole tick every minute.
- The reconciler skips groups it can't authenticate (logging why), leaving those
  deposits `confirmed` for manual resolution.
- Also guards against an EMPTY config snapshot (legacy rows): pick a deposit that
  carries one, skip the group otherwise, instead of building a client from "" —
  which threw "Invalid URL: " and took down healthy deposits in the same group.

Known limitation: a deposit to an external/unverified recipient has no member to log
in as and is skipped as `unavailable`; that needs a service/operator SPC user or
manual resolution.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(private-channels): resolve duplicate fetchCustodyWallets after the dev merge

Merging dev (PR #4) left `apps/sdp-web/src/lib/private-channels.ts` declaring
`fetchCustodyWallets` twice — git auto-merged both sides without a conflict because
each added the function in a different region. That breaks the dashboard build:
"Module parse failed: Identifier 'fetchCustodyWallets' has already been declared".

The two are not interchangeable:
- deposits' hits `/v1/wallets` (the deposit source picker)
- dev's hits `/v1/wallets?includeAllProviders=true` (the wallet-verify picker)

dev owns the `fetchCustodyWallets` name, so ours is renamed
`fetchSignableCustodyWallets`, keeping the narrower behaviour the deposit flow needs:
a deposit is server-signed via createOrgSigner, so the picker must only offer wallets
we can actually sign from — the all-providers list would let a user pick one that
fails at submit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(private-channels): resolve gateway auth after the cheap snapshot guard

`resolveOwnerGatewayAuth` does two DB lookups plus an SPC login (a network
round-trip) — it was running BEFORE the pure in-memory snapshot check, so a credit
group with no usable config snapshot burned a full login before being skipped.

Swap the order: cheap guard first, I/O-heavy identity resolution only for groups we
will actually read a balance for. Commented so it doesn't get "tidied" back.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(private-channels): track the deposit review findings for follow-up PRs

Records the six review findings against the deposit flow with a verified code anchor,
fix direction, and severity calibration for each, organised by urgency rather than by
the order they were reported:

- P0 (live devnet path): non-idempotent POST /deposits, and a broadcast transport
  error wrongly treated as "no funds moved". Coupled — an idempotency key alone
  doesn't help if the signature isn't persisted before send.
- P1 (fold into the settlement-ledger rework): snapshot-blind credit grouping, the
  balance-decrease failure of cumulative accounting, and the missing settlement
  timeout / manual-review state.
- P2 (independent): the oldest-100 scan starvation, test coverage, and web tab gating.

These are deliberately NOT fixed in this PR — they ship as separate PRs so this one
stays reviewable. The reconciler already references finding #3 in a code comment, so
the doc lives with the branch rather than outside it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(private-channels): SPC escrow deposits (gateway client, balance, deposit flow) (#8)

* feat(private-channels): SPC gateway JSON-RPC client + token balance read

Add `@sdp/private-channels/gateway`: `createChannelGatewayRpc` (an `@sdp/rpc`
Kit client re-pointed at the SPC gateway, with a headers seam reserved for the
future JWT `Authorization`) and `getChannelTokenBalance` (derives the CLASSIC
Token program ATA, treats a missing account as a zero balance). This is the
config-source-agnostic substrate every later flow (balances, transfers,
withdraw settlement) reads through.

Wire the `@sdp/rpc` + classic `@solana-program/token` deps and the `./gateway`
export; add the `PrivateChannelBalance` DTO; add unit tests (fake RPC) and a
slim, app-free live-connectivity suite against the sandbox gateway.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(private-channels): add GET /balance on the persisted instance

Expose `GET /v1/private-channels/balance?owner=&mint=` (feature-gated,
`payments:read`). The handler loads the project's ACTIVE persisted instance
via the instance repository, resolves `owner` from a walletId/pubkey/address,
and reads the balance through the gateway client. `mint` defaults to the
instance cluster's USDC mint (cluster inferred from `chainRpcUrl`).

Balances are per (wallet, mint) and shared across the wallet's logical
channels. The read is intentionally under-gated for now — a TODO documents the
admin/self/co-member visibility gate that lands with the member tables in a
later slice. Includes OpenAPI schema + path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(private-channels): escrow deposit instruction client (@sdp/spc-escrow)

Add a `@solana/kit` client for the SPC escrow program, generated from the
vendored Codama IDL (`scripts/generate.ts`, re-runnable via `pnpm generate`).
The generator repoints the IDL's placeholder program id `GokvZqD2…` to the real
devnet deployment `9tgHa1…` and recomputes the eventAuthority / allowedMint /
ATA PDAs under it, so `getDepositInstructionAsync({payer,user,instance,mint,
amount,recipient})` is turnkey (the "3 devnet gotchas" are handled in codegen).

Generated output is committed; a `kit-augment.ts` shim + `rootOnly` import
strategy keep it compatible with the repo's pinned `@solana/kit` 6.8. Unit tests
assert the 12-account order, discriminator 6, program-id override, and classic
Token program. `biome.json` excludes the generated dir from lint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(private-channels): deposit backend — persistence, API, credit reconciler

Add the escrow deposit flow on the API side:
- Migration 0027 `private_channel_deposits` + repository (status
  prepared→submitted→confirmed→credited→failed; amount + baseline_credited as
  TEXT decimal strings; reconciler + listing indexes).
- `services/private-channels/deposit.ts`: `createChannelDeposit` resolves the
  custody signer (escrow `user`), captures the recipient's baseline channel
  balance, builds the deposit ix via `@sdp/spc-escrow`, and runs the shared
  signing pipeline — but broadcasts to the instance's own chain (`chainRpcUrl`,
  devnet) via `signAsFeePayer` + `sendTransaction`, then confirms inline. Shared
  `mint.ts` (extracted from balance.ts) infers cluster + default USDC mint.
- Routes `POST /deposits`, `GET /deposits`, `GET /deposits/:id` (feature-gated;
  write/read permissions) + `PrivateChannelDeposit` DTO + OpenAPI.
- Credit reconciler `jobs/track-pending-deposits.ts`: submitted→confirmed via
  getSignatureStatuses on the instance chain, confirmed→credited once the gateway
  balance reflects the deposit, plus stale-fail. Wired into the CF `scheduled`
  handler and the Node cron runner, gated on the Private Channels feature flag.

The deposit read/create is intentionally under-gated for now (any project member
with the permission can act for any owner); the admin/self/co-member visibility
gate lands with the member tables in a later slice (TODO in the handler).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(private-channels): deposit page with live progress

Add a Deposit tab + page under Private Channels. The form (custody wallet
picker, amount, optional recipient) submits via a server action; on success it
swaps to a live progress view that polls the deposit every 1.5s and renders a
3-stage stepper — "Sent to devnet escrow" → "Confirmed on devnet" → "Credited in
the channel" — stopping on a terminal state, with a devnet explorer link. Data
helpers added to `lib/private-channels.ts`; the tab is placed above Instance per
the header-tabs convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(private-channels): live devnet deposit integration test

Add an app-free live test that builds the escrow deposit with `@sdp/spc-escrow`,
signs it with a funded devnet keypair (payer + user), broadcasts to devnet,
confirms, and asserts the operator credits the channel (read via the gateway).
Gated behind PRIVATE_CHANNEL_DEPOSIT_SECRET_KEY + PRIVATE_CHANNEL_CHAIN_RPC_URL
(a funded keypair with devnet USDC + SOL); skips otherwise, so CI is unaffected.
This automates the deposit checkpoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(private-channels): deposit self-pays fees (Kora escrow allowlist)

The hosted Kora relay only sponsors transactions that touch allow-listed
programs, and the escrow program 9tgHa1… is not on it, so `signAsFeePayer`
rejected the deposit ("Program 9tgHa1… is not in the allowed list"). Until the
escrow program is added to Kora's allowlist, the custody wallet pays its own fee:
it is now both the escrow `user` and the `payer` / tx fee payer, and the tx is
fully signed by that single signer. A TODO(gasless) documents how to restore the
sponsored fee-payer path once the program is allow-listed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(sdp-api): fire scheduled() cron in local dev

`wrangler dev` doesn't run cron triggers on a timer, so the reconcilers (pending
transfers, private-channel deposits) never fired under `dev:local` and statuses
stalled (e.g. a deposit stuck at `confirmed`, never reaching `credited`).

Add `--test-scheduled` to the wrangler args so `/__scheduled` is exposed, plus a
cron ticker in the dev process that GETs `/__scheduled` every 60s — the same
cadence as the production `crons` trigger — so `scheduled()` runs locally too.
Opt out with SDP_API_DISABLE_CRON_TICKER=1; tune with SDP_API_CRON_TICK_MS.
Dev-loop only; production is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(private-channels): address deposit reconciliation review (3 correctness bugs)

- Transient confirm error no longer fails a broadcast deposit. Once a signature
  exists, a confirmTransaction transport/timeout error leaves the deposit
  `submitted` for the reconciler to finalize; only an on-chain `confirmation.err`
  is terminal. Extracted `confirmAndPersistDeposit` so the semantics are testable.

- Cumulative credit accounting for concurrent deposits. The gateway exposes only
  an aggregate (recipient, mint) balance, so crediting each deposit against its
  own baseline let two concurrent deposits both credit off one balance increase.
  New pure `planDepositCredits` walks a recipient's deposits oldest-first and
  credits a `confirmed` deposit only when the balance covers the cumulative
  threshold through it. Reconciler now groups by (instance, recipient, mint) and
  fetches the full group via the new `listDepositsForRecipient` repo method.

- Reconciler uses the deposit's persisted `instance_id` (new `getById`, incl.
  inactive), not the project's currently-active instance — so a disconnect/
  reconnect can't query the signature/credit against the wrong chain/gateway.

Adds unit tests for all three failure modes: confirmation timeout stays
submitted, concurrent deposits credit only what the balance supports, and the
reconciler loads the persisted instance (not getActiveByProject). 17 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(private-channels): replace NUL-byte separators in reconciler group key

creditGroupKey used literal \0 bytes as separators, which made git treat the
file as binary and hid it from text search/review tools. Use a visible '|'
(ids are base58/uuid, no collision). Addresses review Low finding.

* feat(private-channels): harden deposit persistence + emit activity events

Addresses the deposit-reconciliation review + integrates the merged event model:
- Renumber migration 0027 -> 0029 (dev merged events at 0028).
- Deposits are financial records: drop the instance FK so a deposit SURVIVES
  instance deletion (like private_channel_events); the delete handler now rejects
  deleting an instance while non-terminal deposits exist (countNonTerminalByInstance).
- Snapshot the reconciliation context (gateway_url/chain_rpc_url/escrow_*) on each
  deposit row; the service populates it and the reconciler uses the SNAPSHOT — not
  the instance's current (possibly-reconnected/mutated) config — so a reconnect
  can't move the chain/gateway a pending deposit is reconciled against.
- Compare-and-swap status transitions (updateDeposit `expectedStatus`) so concurrent
  workers can't regress/overwrite state.
- Emit transfer.deposit.submitted (on broadcast) and transfer.deposit.credited (on
  credit) into private_channel_events via the runtime event service (shared
  deposit-events.ts, usable from the request path and the cron reconciler).

Unit tests updated: reconciler now asserts snapshot-config use + CAS + event emit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(private-channels): gate deposit + balance routes to project admins (interim)

The merged user-mgmt work added members/verified-wallets, but the deposit and
balance routes still act on financial data / custody wallets independent of that
model — any project member with payments:* could deposit to arbitrary addresses or
read arbitrary SPC balances. Until wallet verification lands and we can gate to
"own verified wallet / channels you belong to", require `projects:admin` IN
ADDITION to payments:* on POST/GET /deposits and GET /balance. Interim; revisit
when the verification slice lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(private-channels): pass the SPC JWT to the gateway client

The gateway JWT-gates private reads (token balances). PR #4 added the machinery to
mint an SPC token — `getSpcSession` logs in as the acting member's SPC user against
the instance's AUTH service (`authUrl`, a different service from `gatewayUrl`) — but
nothing wired it into the gateway transport. This does that for the request paths.

- New `services/private-channels/auth/gateway-auth.ts`:
  `resolveGatewayAuthToken` returns the bearer token for gateway reads, or
  `undefined` when the instance has auth DISABLED (open deployments keep working).
  When auth IS enabled it fails with a clear FORBIDDEN if the caller has no user
  identity (API-key auth can't mint a session) or isn't an invited member — better
  than an opaque gateway 401. `gatewayAuthOptions` builds the header object.
- `getChannelBalance` takes an optional `authToken` and passes
  `{ Authorization: Bearer … }` through `createChannelGatewayRpc` (the header seam
  that was already there).
- `GET /balance` and `POST /deposits` (its gateway baseline read) resolve the
  caller's token and pass it down.

NOT covered — the cron credit reconciler still reads the gateway unauthenticated
and will 401 on an auth-enabled instance, stalling credit detection at `confirmed`.
It has no user identity to log in as; resolving that needs a deliberate choice of
SPC identity (map the recipient pubkey back to its member — the verified-wallet
repo has no by-pubkey lookup yet — or a service/operator SPC user). Documented as a
TODO at the call site and tracked with the settlement-ledger rework.

Note the load-bearing assumption this makes testable: that a token minted by the
SPC AUTH service is accepted by the GATEWAY. `GET /v1/private-channels/balance` is
the cheapest probe.

Verified: api typecheck + biome clean; 27 service/job node tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(private-channels): authenticate the credit reconciler via the recipient's SPC identity

Closes the last gap blocking `confirmed -> credited` on an auth-enabled gateway. The
cron had no request user, so its balance read went out unauthenticated and the
gateway answered 401, stalling every deposit at `confirmed`.

The identity is derived from the data: the credit recipient's pubkey must have been
VERIFIED by a member, mapping `(instance_id, pubkey) ->
private_channel_verified_wallets.user_id -> private_channel_users` — the member whose
stored SPC credential can mint a token. Reading a member's own balance under their own
SPC identity is the natural attribution.

Adapted to dev's post-review model (PR #4): verifications are now INSTANCE-scoped
(uniqueness `user_id + instance_id + pubkey`), so the reverse lookup is
`findByInstanceAndPubkey(instanceId, pubkey)` rather than org/project-scoped. That's
also the right grain — a verification doesn't transfer across instances, and a job
always knows the deposit's instance.

- `resolveOwnerGatewayAuth` returns a discriminated result: `open` (auth off),
  `token`, or `unavailable` with a reason. It NEVER throws — an unresolvable identity
  must not fail the whole tick every minute.
- The reconciler skips groups it can't authenticate (logging why), leaving those
  deposits `confirmed` for manual resolution.
- Also guards against an EMPTY config snapshot (legacy rows): pick a deposit that
  carries one, skip the group otherwise, instead of building a client from "" —
  which threw "Invalid URL: " and took down healthy deposits in the same group.

Known limitation: a deposit to an external/unverified recipient has no member to log
in as and is skipped as `unavailable`; that needs a service/operator SPC user or
manual resolution.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(private-channels): resolve duplicate fetchCustodyWallets after the dev merge

Merging dev (PR #4) left `apps/sdp-web/src/lib/private-channels.ts` declaring
`fetchCustodyWallets` twice — git auto-merged both sides without a conflict because
each added the function in a different region. That breaks the dashboard build:
"Module parse failed: Identifier 'fetchCustodyWallets' has already been declared".

The two are not interchangeable:
- deposits' hits `/v1/wallets` (the deposit source picker)
- dev's hits `/v1/wallets?includeAllProviders=true` (the wallet-verify picker)

dev owns the `fetchCustodyWallets` name, so ours is renamed
`fetchSignableCustodyWallets`, keeping the narrower behaviour the deposit flow needs:
a deposit is server-signed via createOrgSigner, so the picker must only offer wallets
we can actually sign from — the all-providers list would let a user pick one that
fails at submit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(private-channels): resolve gateway auth after the cheap snapshot guard

`resolveOwnerGatewayAuth` does two DB lookups plus an SPC login (a network
round-trip) — it was running BEFORE the pure in-memory snapshot check, so a credit
group with no usable config snapshot burned a full login before being skipped.

Swap the order: cheap guard first, I/O-heavy identity resolution only for groups we
will actually read a balance for. Commented so it doesn't get "tidied" back.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(private-channels): track the deposit review findings for follow-up PRs

Records the six review findings against the deposit flow with a verified code anchor,
fix direction, and severity calibration for each, organised by urgency rather than by
the order they were reported:

- P0 (live devnet path): non-idempotent POST /deposits, and a broadcast transport
  error wrongly treated as "no funds moved". Coupled — an idempotency key alone
  doesn't help if the signature isn't persisted before send.
- P1 (fold into the settlement-ledger rework): snapshot-blind credit grouping, the
  balance-decrease failure of cumulative accounting, and the missing settlement
  timeout / manual-review state.
- P2 (independent): the oldest-100 scan starvation, test coverage, and web tab gating.

These are deliberately NOT fixed in this PR — they ship as separate PRs so this one
stays reviewable. The reconciler already references finding #3 in a code comment, so
the doc lives with the branch rather than outside it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(private-channels): drop DEPOSIT_REVIEW_FIXES.md from the repo

The deposit review findings move out of the tree and live alongside the other
planning docs (next to private-channels-settlement-ledger-design.md), so the branch
carries only shippable code. The fixes themselves land as separate PRs, unchanged.

Also drops the one code reference that would otherwise dangle: the reconciler's note
about snapshot-blind credit grouping now just says it's a known follow-up landing
with the settlement-ledger rework, rather than pointing at a file that isn't here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(private-channels): address PR #8 review feedback

Four review comments from @nbaztec, plus one drive-by fix.

- scripts/simulate-clerk-webhook.ts removed from the branch. It was never meant to
  be here: it's a local scratch script that an over-broad `git add` swept into the
  dev merge (the same one that briefly caught .dev.vars.bak). Untracked, not deleted
  — it stays on disk for local use.

- dev-local.mjs: answered "aren't we running in workerd?" in the comment itself.
  We are, and we DO use workerd's own scheduled() path — `--test-scheduled` mounts
  it at /__scheduled and that's exactly what the ticker calls. What local dev has no
  equivalent of is the SCHEDULER: `wrangler dev` never fires the wrangler.toml
  `crons` entry itself, so without a poke the reconcilers never run locally. The
  ticker supplies only the missing timer; the execution path is workerd's.

- 0029 migration: TODO(snapshot-recovery) on the snapshot columns. Immutability cuts
  both ways — it stops a reconnect silently moving the chain a pending deposit
  reconciles against, but it also pins a deposit to an endpoint that may die. If
  chain_rpc_url goes down or runs out of credits and the operator repoints the
  instance, deposits snapshotted against the old URL can never confirm. Needs an
  explicit, audited operator action to re-point a stuck deposit, rather than
  silently following the instance or stranding the row.

- instance.ts: TODO(disconnect-drain) on the delete guard. Agreed it's check-then-act
  — a deposit created between the count and the delete still slips through. The
  guard catches the common case but is not a barrier; the real fix is a
  draining/read-only instance state that stops accepting new deposits/transfers,
  lets the in-flight set settle, then permits the delete.

Drive-by (not from review): runner.node.test.ts now mocks ./pending-deposits. Without
it the test loads the real reconciler, which fails to resolve (@solana/mosaic-sdk
ships a directory import Node ESM rejects) — and because vitest shares a pool, that
also took down track-pending-deposits.node.test.ts. Both files were red on this
branch; the suite is now 7 files / 44 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(private-channels): SPC withdrawals (burn → gateway relay → devnet release) (#9)

* feat(private-channels): @sdp/spc-withdraw codama client + withdrawal plan

Deliverable 1 of the withdrawal flow (see WITHDRAWAL_PLAN.md): a @solana/kit
client for the SPC withdraw program, generated from the vendored Codama IDL.

The withdrawFunds instruction is an SPL Burn (discriminator 0; args amount:u64,
destination:Option<Pubkey>; 5 accounts: user signer, mint, tokenAccount = the
user's classic-Token ATA, tokenProgram, associatedTokenProgram). Unlike escrow
there is no program-id override and no PDAs — the IDL already carries the real
deployed id (J231K9…); the builder auto-derives the user's ATA so callers pass
only domain inputs: getWithdrawFundsInstructionAsync({ user, mint, amount, destination }).

Exposes getWithdrawFundsInstructionAsync, WITHDRAW_FUNDS_DISCRIMINATOR, and
PRIVATE_CHANNEL_WITHDRAW_PROGRAM_ADDRESS. Generate + typecheck + unit tests green.
biome ignores the generated dir (mirrors @sdp/spc-escrow).

WITHDRAWAL_PLAN.md captures the full flow: user signs the burn, SDP relays it to
the GATEWAY (not devnet), the operator later releases real USDC on devnet, and SDP
detects that release (unauthenticated getSignaturesForAddress on the instance ATA)
— so withdrawal release-detection can become authoritative before deposit crediting
(which is blocked on the gateway JWT work).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(private-channels): withdrawal data layer (intent table + repo + DTO)

The persistence foundation for the withdrawal flow (WITHDRAWAL_PLAN.md §4.1),
mirroring the hardened deposit shapes (0029):

- Migration 0030_private_channel_withdrawals.sql — the withdrawal intent table.
  Like deposits it's a financial/audit record: instance_id is denormalized with
  NO FK (survives instance deletion) and the reconciliation context (gateway/chain
  RPC/escrow instance) is SNAPSHOTTED on the row so a later reconnect can't move
  the chain a pending withdrawal reconciles against. Status CHECK enforces the
  state machine: pending -> submitted -> burn_confirmed -> release_pending ->
  released, plus failed (pre-burn only) and manual_review. Partial indexes keep
  the reconciler scan + delete guard cheap as terminal rows accumulate.
  `release_signature` is the devnet settlement correlation (settlement_ref).

- private-channel-withdrawal.repository{,.postgres}.ts — CAS updates
  (`expectedStatus`) so concurrent reconciler workers can't regress state;
  COALESCE preserves burn_signature/release_signature across transitions;
  listWithdrawalsByStatus (reconciler scan) + countNonTerminalByInstance (delete
  guard). Registered in repository-factory + the barrel.

- PrivateChannelWithdrawal DTO + PrivateChannelWithdrawalStatus in @sdp/types.

Note: NEVER auto-`failed` after burn_confirmed — the balance is already burned, so
an unobservable release is a settlement issue (manual_review), symmetric to the
deposit "never failed after on-chain confirmation" rule.

Verified: repo integration test (6/6) against Postgres exercises the 0030
migration + CAS + COALESCE + non-terminal scans; api + types typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(spc): share the @solana/kit ExtendedClient shim across codama clients

sdp-api now imports BOTH codama clients (@sdp/spc-escrow for deposits, @sdp/spc-
withdraw for withdrawals). Each shipped its own src/kit-augment.ts declaring
`export type ExtendedClient` on `@solana/kit` (the kit-6.8 shim). Type aliases
don't merge, so two identical declarations in one compilation collide
(TS2300 "Duplicate identifier").

Extract the augment into a single shared leaf package `@sdp/kit-augment` that both
clients side-effect import; a consumer pulling in multiple clients now sees the
`declare module` exactly once. A type-only `import {} from "@solana/kit"` anchors
the augmentation so the shim also typechecks standalone.

Also fix a pre-existing type error in the escrow deposit test surfaced once its
typecheck resolved cleanly: `recipient` was typed `string | null` instead of
`Address | null` (getDepositInstructionAsync expects OptionOrNullable<Address>).

Verified: @sdp/kit-augment, @sdp/spc-escrow (4/4), @sdp/spc-withdraw (5/5), and
@sdp/api all typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(private-channels): withdrawal service + routes + OpenAPI

The withdrawal runtime path (custody-signed burn → gateway relay), mirroring the
deposit flow but broadcasting to the GATEWAY (channel chain) instead of devnet:

- services/private-channels/withdraw.ts — createChannelWithdrawal: validates +
  persists (pending) with the reconciliation snapshot, server-signs the burn with
  the custody wallet (sole signer; self-pays the channel-chain fee for now), builds
  it via @sdp/spc-withdraw, broadcasts to createChannelGatewayRpc, and moves to
  submitted. A pre-broadcast failure is a legitimate terminal `failed` (no balance
  moved) — the ONLY path to failed. Plus getChannelWithdrawal / listChannelWithdrawals.
- withdraw-confirm.ts — confirms the burn on the gateway; a transport/auth error
  (incl. the gateway declining the read without a JWT) leaves it `submitted` for the
  reconciler; only a real on-chain burn error is terminal `failed`; confirmed →
  burn_confirmed. After burn_confirmed the reconciler NEVER auto-fails.
- withdraw-events.ts — emits transfer.withdrawal.submitted/released activity events.
- Routes: POST/GET /withdrawals + GET /withdrawals/:id, admin-gated (payments:* +
  projects:admin) like deposits; zod schemas; OpenAPI schema + paths (openapi.json
  regenerated).
- Delete-instance guard now also rejects while non-terminal withdrawals exist
  (Promise.all over both deposit + withdrawal counts).

Gateway-auth caveat (WITHDRAWAL_PLAN §6.1): the burn broadcast is a gateway WRITE
and confirmation a gateway READ — both may be JWT-gated. Broadcasts unauthenticated
today (the empirical test); the createChannelGatewayRpc header seam is ready for the
JWT once wallet-verification lands.

Verified: api typecheck clean; node (21) + workers withdrawal-repo/route tests (14) pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(private-channels): withdrawal reconciler + cron wiring

services/jobs/track-pending-withdrawals.ts drives non-terminal withdrawals each
cron tick, each against its OWN snapshotted config (CAS transitions throughout):
- pending w/o burn sig, stuck >5min → failed (never broadcast).
- submitted → burn_confirmed/failed via the burn's GATEWAY signature status. This
  is the ONLY auto-`failed` window (pre-confirmation; no balance moved).
- burn_confirmed → release_pending (bookkeeping; enter release-watching).
- release_pending → released when the operator's devnet release is detected on the
  instance escrow ATA: scan getSignaturesForAddress(instanceAta) once per
  (instance, mint, chain) bucket, getTransaction, match an outgoing SPL transfer by
  (destinationAta, mint, base-unit amount), FIFO within the single-flight bucket;
  record the release signature as the settlement correlation. A release unseen past
  the timeout → manual_review, NEVER failed (the balance is already burned).

Release-detection assumes the operator releases from the instance's classic-Token
ATA to the destination's ATA (WITHDRAWAL_PLAN §6.2 — confirm the exact release-tx
shape + whether a memo/withdrawId is attached with the SPC team).

cron/pending-withdrawals.ts wraps it with the Sentry monitor + BackgroundRunner,
wired into both the node-cron runner and the CF scheduled() handler under the
Private Channels flag. Adds @solana-program/token (classic) for ATA derivation.

Also repairs runner.node.test.ts, which failed to load on a @solana/mosaic-sdk ESM
directory-import: it mocked pending-transfers/recurring-payments but not
pending-deposits (added by the deposit work) — now mocks both private-channels
wrappers + asserts they schedule + fire when the flag is on.

Verified: reconciler node tests (6/6), runner tests (16/16), api typecheck + biome clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(private-channels): withdrawal web page + Withdraw tab

Mirrors the Deposit page under Private Channels (Payments):
- withdraw/page.tsx — RSC feature guard + Clerk guards + instance-active redirect,
  loads custody wallets.
- withdraw/withdraw-form.tsx — wallet picker + amount + optional `destination`
  ("Withdraw from" / "Release to"); submit hands off to the progress view.
- withdraw/withdraw-progress.tsx — 4-stage stepper polling fetchWithdrawalAction
  every 1.5s: submitted → "Burn sent", burn_confirmed → "Burn confirmed (balance
  debited)", release_pending → "Awaiting devnet release", released → "Released on
  devnet". Terminal states (released/failed/manual_review) stop polling; failed
  shows a destructive box, manual_review an amber box (both with failureReason).
  The burn signature renders as text (channel-chain tx, no devnet explorer); the
  release signature links to the devnet explorer.
- withdraw/actions.ts — createWithdrawalAction ({ok,kind} union) + fetchWithdrawalAction
  poll target, with revalidatePath.
- lib/private-channels.ts — createPrivateChannelWithdrawal / fetchPrivateChannelWithdrawal
  / fetchPrivateChannelWithdrawals helpers.
- Added a Withdraw tab (requiresActive) after Deposit.

Verified: sdp-web typecheck clean; polling mirrors the deposit server-action pattern
(no api proxy route). See WITHDRAWAL_EMPIRICAL_TEST.md to drive the live devnet run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(private-channels): withdrawal empirical gateway-auth devnet test runbook

Step-by-step to answer WITHDRAWAL_PLAN §6.1 on a live devnet run: prerequisites (a
custody wallet with a channel balance to burn), the POST /withdrawals call, and a
decision table classifying the resulting status — `failed` w/ an Unauthorized/JWT
reason ⇒ gateway writes are JWT-gated (submit blocked); `submitted`/`burn_confirmed`
⇒ writes open. Plus driving the reconciler + what to capture for the SPC team
(release-tx shape / matchability).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(private-channels): authenticate the withdrawal flow against the SPC gateway

Applies the deposit branch's gateway-auth work to withdrawals. Withdrawals lean on
the gateway MORE than deposits: the burn broadcast is a gateway WRITE, and both the
inline confirm and the reconciler's burn-confirmation are gateway READS — all
JWT-gated. Only devnet release detection stays unauthenticated (devnet is public),
which is why a withdrawal can still settle where the gateway is locked down.

- Request path: the handler resolves the caller's SPC session and threads it through
  createChannelWithdrawal -> broadcastWithdrawal (burn write) and
  confirmAndPersistWithdrawal (confirm read).
- Cron path: burn confirmation derives the identity from the burn OWNER's verified
  wallet via resolveOwnerGatewayAuth, the same mechanism deposits use for the credit
  recipient.

`unavailable` (e.g. an unverified owner) leaves the withdrawal `submitted` for a
later tick or manual resolution — deliberately NOT failed: we can't tell whether the
burn confirmed, and auto-failing a possibly-burned balance is exactly what the state
machine exists to prevent.

Web: the withdraw page was using dev's BROAD fetchCustodyWallets
(includeAllProviders) after the merge — wrong for a picker whose wallets must be
server-signable via createOrgSigner. Points at fetchSignableCustodyWallets instead.

Migration numbering needs no change: dev's PR #4 review squashed its baseline, so
deposits sits at 0029 and withdrawals keeps 0030.

Verified: api + web + types typecheck clean; 53 node tests pass, incl. new coverage
that the owner's token reaches the gateway client and that an underivable identity
leaves the burn untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(private-channels): drop the withdrawal planning docs from the repo

WITHDRAWAL_PLAN.md and WITHDRAWAL_EMPIRICAL_TEST.md move out of the tree to sit with
the other planning docs, so the branch carries only shippable code. Both served their
purpose: the plan drove the implementation, and the runbook drove the devnet test
that now passes.

Updates the three code comments that cited them — and two were stale anyway:
- withdraw.ts: the `TODO(gateway-auth)` still claimed the burn "broadcasts
  unauthenticated (the empirical test)". It doesn't: the handler resolves the
  caller's SPC session and the flow is verified end-to-end against an auth-enabled
  gateway. Rewritten to describe what the code actually does.
- track-pending-withdrawals.ts: the release-shape NOTE was written as an unverified
  assumption pending SPC confirmation. The live release confirmed it, so it now
  states the confirmed behaviour and keeps only the real caveat — content matching
  can't disambiguate withdrawals sharing (destinationAta, mint, amount).
- withdraw.ts fee-model TODO keeps its substance, minus the section reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(private-channels): renumber verified-wallets migration (#10)

* remove optional auth, allow developers payments

* feat(private-channels): cache SPC session JWT + refresh-on-401 gateway auth

Cache the SPC-issued gateway JWT per (instance, SPC user) in KV — encrypted at
rest, refreshed before expiry, best-effort (any KV/decrypt failure degrades to a
fresh login) — instead of logging in on every gateway call.

Gateway auth is now a self-refreshing handle: callers hold a GatewayAuthHandle
and run gateway ops through withGatewayRpc, which retries once on a strict
gateway 401 with a re-minted token. Adds isUnauthorizedRpcError (status 401
only) to @sdp/rpc. Wallet verify/delete stay uncached (rare, different error
channel).

* refactor(private-channels): unify SPC auth on a shared cached session handle

Wallet verify/delete previously minted a fresh, uncached SPC JWT per call and
never retried a stale token. They now build the SAME KV-cached session handle as
the gateway reads and retry once on a 401 — one auth path for both surfaces.

- Rename GatewayAuthHandle -> SpcAuthHandle (token is now a non-nullable string).
- Export openSpcAuthHandle; it owns the KV lookup (tryGetCache) instead of taking
  a cache arg, so resolveGatewayAuth and the wallet write path mint the handle
  identically.
- Add withSpcAuth(handle, run): retry-once on PrivateChannelError UNAUTHORIZED
  with a re-minted token, mirroring withGatewayRpc's gateway-RPC 401 retry. For
  verify the retry unit is challenge -> sign -> verify (restarted from challenge
  since the nonce is challenge-scoped); the signer is resolved once, outside it.
- balance/deposit/withdraw/withdraw-confirm: SpcAuthHandle type rename only.
- Tests: withSpcAuth unit coverage; wallet verify/delete 401-retry + shared-cache
  assertions; integration coverage for cache sharing + auth-REST 401 re-mint.

* chore(private-channels): fix biome lint violations

CI Lint runs biome over files changed vs origin/main and was failing on the SPC
feature stack:
- format long object-literal args + sort imports/exports (test files,
  gateway-auth, wallets, sdp-private-channels barrel).
- annotate the implicitly-any `let registered` in members.ts.
- drop a useless empty export in kit-augment.
- ignore the generated escrow/withdraw `idl/*.json` (biome already ignores the
  sibling `src/generated`); they are codegen output, not hand-edited.

* fix(docker): bundle private-channels + spc packages in api/web images

sdp-api now imports @sdp/private-channels, @sdp/spc-escrow and @sdp/spc-withdraw
(the latter two pull @sdp/kit-augment), and sdp-web imports @sdp/private-channels,
but the image builds never copied those workspace packages — so esbuild
(`Could not resolve "@sdp/private-channels"`) and the Next.js build both failed.

- sdp-api Dockerfile: copy the four packages in the deps (package.json) and
  builder (source) stages; add them to the Dockerfile.dockerignore allow-list
  (the api context blanket-excludes packages/* then re-includes by name).
- sdp-web Dockerfile: copy sdp-private-channels (its rpc/types deps are already
  present; the web context has no packages/* blanket exclude).

Verified locally: both `docker build` runs complete (esbuild + `next build`).

* chore(private-channels): drop filler "real" from auth comments and test labels

Describe what the code does without the "real X" emphasis (real KV/DB/retry/
classifier/infrastructure/SPC failure) — the surrounding words already say it.
Comment- and test-label-only; no behavior change.

* fix reset

* refactor(private-channels): rename SpcAuthHandle to SpcAuthContext

Address review feedback that Handle implies an open connection to manage.

* test(private-channels): seed auth_url + default channel for enforce-always schema

Three repo/route tests seeded a private_channel_instances row via raw SQL
without auth_url, which is now NOT NULL (nish's "remove optional auth"
migration). Add auth_url to those inserts.

The channels-route test additionally assumed GET /channels lazily creates
the default channel — behaviour the refactor moved to the instance-connect
handler (getOrCreateDefault). Seed the default private_channels row in
seedAuth() to mirror connect, and correct the stale "ensures on GET" title
and comment.

Restores test:workers to the known flaky baseline (custody-switch + the 3
signing.service.reuse tests); private-channels suites fully green.

* fix(ci): clear biome lint errors and noNonNullAssertion warnings

The Lint CI job (biome check over the branch diff vs origin/main) failed on
3 errors; the remaining CI failures are all the unconfigured DOPPLER_TOKEN_CI
secret and are out of scope here.

- Format scripts/dev-local.mjs and scripts/simulate-clerk-webhook.ts.
- Reduce interpretApiError cognitive complexity (35 → under 30) by extracting
  the connection-probe branch into interpretProbeError + summarizeProbeFailure
  (the nested ternary becomes an early-return chain). Behaviour-preserving.
- Clear the 16 noNonNullAssertion warnings: add a guarded seed() helper in the
  withdrawal repo test (drops 12 `created!`/`a!`/`b!`/`other!`), and drop the
  stale `auth!` (resolveGatewayAuth is now non-nullable) + guard `pcUser!` in
  the gateway-auth integration test.

* test(api): expect Private Channels in the public OpenAPI document

The merge registered the private-channels family in both the internal and
the public OpenAPI documents, which trips main's guard test pinning the
public tag list.

Keep it public and update the guard instead. The routes are feature-gated
by PRIVATE_CHANNELS_ENABLED and 403 when off, but API keys can call them
(unifiedAuthMiddleware tries the API-key path before the Clerk/session
options), so they belong in the published contract. Asset Profiles is the
existing precedent: flag-gated and publicly documented.

* refactor(web): gate Private Channels through Vercel Flags

main moved web product rollouts from build-time NEXT_PUBLIC_* env vars to
runtime Vercel Flags (#894), deleting lib/asset-profiles-feature.ts in the
process. lib/private-channels-feature.ts was still that deleted file's
pattern verbatim, read directly inside the dashboard-shell client
component.

Register a private-channels flag alongside asset-profiles, resolve its
default from the server-side PRIVATE_CHANNELS_ENABLED, eva…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants