Skip to content

Deployment

Deployment — Solana Token Distribution Protocol

Section titled “Deployment — Solana Token Distribution Protocol”

Building, testing, and deploying the Solana program, API, and web frontend.

  1. Prerequisites
  2. Building
  3. Testing
  4. Program deployment
  5. API deployment
  6. Frontend deployment
  7. CI/CD
  8. Browser compatibility

  • Rustrustup install stable |- Solana CLI — v3.1.12
  • Anchor CLI — v0.32.1
  • Node.js v24+ + pnpm (install: corepack enable && corepack prepare pnpm@latest --activate)
  • Wrangler CLI — for API deployment (installed as devDependency)

Terminal window
# Install dependencies for all workspaces
pnpm install
# Build everything (packages + program)
pnpm build
ScriptWhat it does
pnpm program:buildBuilds the Anchor program, syncs the IDL into the SDK, builds SDK
pnpm sdk:syncCopies the latest IDL from the program build into the SDK package
pnpm sdk:buildBuilds only the SDK package

To get the program ID after building:

Terminal window
cd apps/solana-tdp-anchor
anchor keys list

Update declare_id!("...") in programs/solana-tdp/src/lib.rs and [programs.localnet] in Anchor.toml with the output, then rebuild.


Tests run on Anchor LiteSVM with LiteSVM underneath — no local validator needed.

Terminal window
# Run all tests (program + SDK)
pnpm test
# Or just the program tests using anchor
cd apps/solana-tdp-anchor
anchor test
# Web app unit + storybook tests
pnpm --filter @simplyvest/dapp test
# Typecheck everything
pnpm check:ts:all
FileWhat it tests
solana-tdp.000.create-stream.test.tsStream creation with valid/invalid parameters
solana-tdp.001.withdraw.test.tsClaiming vested tokens, cliff checks, partial claims
solana-tdp.002.cancel.test.tsMid-stream cancellation, vested/unvested split
solana-tdp.003.milestone.test.tsMilestone stream creation, trigger, withdraw, cancel
solana-tdp.005.security-audit.test.ts19 security audit tests — signer authority, PDA uniqueness, overflow, account ownership, state transitions, wrong-account attacks, timestamp boundaries
fixtures.tsShared test fixtures (token mints, accounts, PDAs)

Terminal window
cd apps/solana-tdp-anchor
# Deploy to devnet
pnpm run deploy
# Verify
solana program show 6VkmhxbTH9dnzAE7Scpxn6R3HeXYtY4oZffAFMAYvECk --url devnet

Program ID: 6VkmhxbTH9dnzAE7Scpxn6R3HeXYtY4oZffAFMAYvECk

NetworkSolana Devnet
Program ID6VkmhxbTH9dnzAE7Scpxn6R3HeXYtY4oZffAFMAYvECk
ExplorerSolana Explorer (devnet)

The API (apps/api) is a Cloudflare Worker with D1 database.

  1. Create D1 database:
    Terminal window
    cd apps/api
    pnpm wrangler d1 create simplyvest-db
  2. Copy the database_id from output into wrangler.toml
  3. Set secrets:
    Terminal window
    pnpm wrangler secret put PRIVY_APP_ID
    pnpm wrangler secret put PRIVY_APP_SECRET
  4. Apply migrations:
    Terminal window
    pnpm db:migrate:remote
Terminal window
pnpm deploy:api
Terminal window
pnpm dev:api # Start API on localhost:8787
pnpm dev:dapp # Start dapp on localhost:5173
pnpm dev # Start API, web, docs, and storybook in parallel
pnpm db:generate # Generate new migration from schema changes
pnpm db:migrate # Apply migrations to local D1
pnpm db:reset # Drop all tables (local only)
MethodPathAuthPurpose
POST/api/streamsRecord new stream
GET/api/streamsList streams
GET/api/streams/:idGet stream + events
POST/api/streams/:id/syncSync stream events
POST/api/streams/:id/eventsRecord stream event
GET/api/users/meJWTGet own profile
POST/api/users/meJWTCreate profile
PUT/api/users/meJWTUpdate profile
GET/api/users/:idGet public profile
POST/api/orgsJWTCreate organization
GET/api/orgs/:idGet org + members
PUT/api/orgs/:idJWTUpdate org
DELETE/api/orgs/:idJWTDelete org
POST/api/orgs/:id/membersJWTAdd member
DELETE/api/orgs/:id/members/:userIdJWTRemove member
GET/api/orgs/me/listJWTList user’s orgs
POST/api/reconcileJWTTrigger reconciliation
GET/api/reconcile/statsJWTReconciliation stats
POST/api/waitlistLegacy waitlist
GET/api/tokens/r2/*Serve R2 metadata JSON
POST/api/tokens/upload-imageJWTUpload token image to R2
POST/api/tokens/metadataJWTStore token metadata
POST/api/tokensRecord token info
GET/api/tokensList tokens
PATCH/api/tokens/:mint/visibilityToggle token visibility
GET/api/tokens/preferencesGet user token preferences
POST/api/tokens/create-platformJWTCreate platform token on-chain
PUT/api/orgs/:id/tokenJWTSet org token (create or link)
DELETE/api/orgs/:id/tokenJWTRemove org token

The documentation site (apps/docs) is an Astro + Starlight static site, deployed to Cloudflare Pages.

Production URL: simplyvest-docs.pages.dev

  1. Create the Pages project (already done):
    Terminal window
    pnpm wrangler pages project create simplyvest-docs --production-branch=main
Terminal window
# Build and deploy
pnpm --filter @simplyvest/docs build
pnpm --filter @simplyvest/docs deploy

The deploy script runs wrangler pages deploy dist --project-name=simplyvest-docs from the package directory.

No secrets, API keys, or environment variables are needed — the docs site is pure static content.


The React frontend (apps/dapp) is deployed to Cloudflare Pages via GitHub Actions. A push to main that touches apps/dapp/ or packages/solana-tdp-sdk/ triggers an automatic build and deploy.

Production URL: simplyvest.pages.dev

Preview deploys are created for pull requests at <branch>.simplyvest.pages.dev.

  1. Create a Cloudflare API token: Dashboard > API Tokens > Create Token > Custom > Account > Cloudflare Pages > Edit

  2. Get your Account ID from the Cloudflare dashboard sidebar

  3. Create the Pages project:

    Terminal window
    pnpm dlx wrangler pages project create simplyvest --production-branch=main
  4. Add two GitHub Actions secrets:

    • CLOUDFLARE_API_TOKEN — your Cloudflare API token
    • CLOUDFLARE_ACCOUNT_ID — your Cloudflare Account ID

    Where to scope the secrets:

    • Repository level (recommended) — Settings > Secrets and variables > Actions > Secrets. Available to all workflows without extra config.
    • Environment level — If scoped to an environment (e.g., main), the workflow job must declare environment: main or the secrets won’t be visible. Our CI workflow’s deploy-web job already includes this declaration.
Terminal window
# Build and deploy
pnpm --filter @simplyvest/dapp build
pnpm --filter @simplyvest/dapp deploy

The deploy script runs wrangler pages deploy dist --project-name=simplyvest from the package directory.

SPA routing works out of the box — Cloudflare Pages auto-detects a client-side router when there is no 404.html and serves index.html for all unmatched paths.


The Storybook component library (apps/storybook) is deployed to Cloudflare Pages as a standalone static site.

Production URL: simplyvest-storybook.pages.dev

  1. Create the Pages project (already done):
    Terminal window
    pnpm wrangler pages project create simplyvest-storybook --production-branch=main
Terminal window
# Build SDK (stories import from @solana-tdp/sdk)
pnpm --filter @solana-tdp/sdk build
# Build and deploy
pnpm --filter @simplyvest/storybook build-storybook
pnpm --filter @simplyvest/storybook deploy

The deploy script runs wrangler pages deploy storybook-static --project-name=simplyvest-storybook from the package directory.


Each job has its own reusable workflow file in .github/workflows/, called by the orchestrator ci.yaml:

Workflow fileJobTriggersWhat it does
ci.yaml(orchestrator)Push/PR to mainCalls all other workflows, deploys on push
lint.yamllintPRs + mainJS/TS lint with oxlint
format.yamlformatPRs + mainFormat check with oxfmt
typecheck-web.yamltypecheck-webPRs + mainTypeScript check (web)
typecheck-api.yamltypecheck-apiPRs + mainTypeScript check (API)
typecheck-sdk.yamltypecheck-sdkPRs + mainTypeScript check (SDK)
typecheck-anchor.yamltypecheck-anchor-tsPRs + mainTypeScript check (anchor)
typecheck-docs.yamltypecheck-docsPRs + mainAstro check (docs)
test-api.yamltest-apiPRs + mainAPI tests with vitest
test-web.yamltest-webPRs + mainUnit tests with vitest (jsdom)
test-e2e.yamltest-e2ePRs + mainPlaywright e2e tests (Chromium, 11 specs)
test-storybook.yamltest-storybookPRs + mainStorybook interaction tests (Playwright)
build-web.yamlbuild-webPRs + mainProduction build of React frontend
rust-lint.yamllint-rustPRs + maincargo fmt + clippy
anchor.yamlanchorPRs + mainBuild Anchor program + vitest tests
deploy-web.yamldeploy-webmain onlyBuild SDK + Deploy web to Cloudflare Pages
deploy-api.yamldeploy-apimain onlyBuild SDK + Deploy API Worker + D1 migrations
deploy-docs.yamldeploy-docsmain onlyBuild + Deploy docs to Cloudflare Pages
deploy-storybook.yamldeploy-storybookmain onlyBuild SDK + Build + Deploy storybook to Cloudflare Pages

Set in Settings → Secrets and variables → Actions → Variables (repo level):

VariableValueUsed by
VITE_API_URLAPI worker URL (e.g., https://simplyvest-api.workers.dev)deploy-web, build-web
VITE_PRIVY_APP_IDPrivy App IDdeploy-web, build-web
VITE_PRIVY_CLIENT_IDPrivy Client IDdeploy-web, build-web
VITE_GA_MEASUREMENT_IDGoogle Analytics IDdeploy-web, build-web
VITE_SENTRY_DSNSentry DSN (optional)deploy-web

Set in Settings → Secrets and variables → Actions → Secrets (environment main):

SecretValueUsed by
CLOUDFLARE_API_TOKENCloudflare API token (Pages + Workers Edit)deploy-web, deploy-api, deploy-docs, deploy-storybook
CLOUDFLARE_ACCOUNT_IDCloudflare Account IDdeploy-web, deploy-api, deploy-docs, deploy-storybook

Why environment-scoped? The deploy jobs declare environment: main, so they can access these secrets. PR workflows don’t have access — this prevents a malicious PR from leaking credentials.

Terminal window
# Variables (repo level)
gh variable set VITE_API_URL --body "https://simplyvest-api.workers.dev"
gh variable set VITE_PRIVY_APP_ID --body "your-app-id"
gh variable set VITE_PRIVY_CLIENT_ID --body "your-client-id"
gh variable set VITE_GA_MEASUREMENT_ID --body "G-XXXXXXXXXX"
# Secrets (environment level)
gh secret set CLOUDFLARE_API_TOKEN --env main
gh secret set CLOUDFLARE_ACCOUNT_ID --env main
# Verify
gh variable list
gh secret list --env main

Solana’s web3.js and wallet adapter libraries use Node.js globals (Buffer, process, global) that do not exist in browsers. The dapp frontend polyfills them in apps/dapp/app/main.tsx:

import { Buffer } from "buffer";
import process from "process";
globalThis.Buffer = Buffer;
globalThis.process = process;

The Vite config also maps global to globalThis:

apps/dapp/vite.config.ts
define: {
global: "globalThis",
}

If a new dependency triggers a crypto is not defined or stream is not defined error at runtime, add the corresponding alias to vite.config.ts:

Missing modulePolyfill packageVite alias
cryptocrypto-browserifyresolve: { alias: { crypto: "crypto-browserify" } }
streamstream-browserifyresolve: { alias: { stream: "stream-browserify" } }

These are rarely needed — most Solana wallet adapters have browser-native fallbacks. Only add them if you hit actual runtime errors.