On-chain event ticketing on Kaspa: tickets are covenant-bound assets, deployed by event organisers and bought / held by attendees — all on chain.
Live: https://kticket.danieliyahu.com/
The chain is the source of truth: the SPA and the API only build, relay and re-verify ticket covenants, and the registry keeps just the identifiers discovery needs.
| Package | Path | Stack | Responsibility |
|---|---|---|---|
@kticket/kit |
packages/kit |
TypeScript (over kaspa-wasm + SilverScript artifacts) |
Covenant WASM kit — on-chain ticket rules, tx building, covenant state decoding, provenance helpers. Shared by api / web. |
@kticket/api |
packages/api |
Node.js + TypeScript (Fastify) | Stateless API — every read re-verifies event data from the chain (memoized); only an identifier registry (deploy_txid, covenant_id, organizer_address) is stored, for discovery. |
@kticket/web |
packages/web |
React + Vite + TypeScript | Monolith SPA — buyer and organiser flows, with a trust-anchor "Organized by" UI and anchor-based discovery links. |
The ticket covenant is a KCC20-fork — a fork of forge's pre-Final 4-field
token layout. It is not an implementation of KCC-20 "Fungible Token
Covenant Specification" Final: it keeps owner, identifier_type, amount,
is_minter, used, and sale_price in its own order, exposes ticket
entrypoints (mint, use, mark_used, list, purchase, delist) instead
of transfer/transfer_delegator, and has no borrow fields and no
extension_commitment.
That gap is deliberate. The door (used) and resale (sale_price) are ticket
semantics, and adopting the Final state record would change the bytecode and
stop every deployed event from verifying. Wallets and explorers are served by
KCC-23 metadata plus the kit's own program artifact, not by a KCC-20
descriptor. The field mapping, interop path, and the adopted stay-a-fork
decision are in
packages/kit/docs/decisions/kcc20-fork.md;
packages/kit/docs/README.md indexes the other
design records.
The chain is the source of truth; the app is a thin wrapper:
- The identifier registry stores only
{ deploy_txid, covenant_id, organizer_address }for discovery — never authoritative. It persists to Turso whenTURSO_DATABASE_URLis set (durable across deploys;TURSO_AUTH_TOKENfor remote databases), and falls back to a localevents.jsonfile otherwise. Turso is required in the deployed environments:compose.yamland the KubernetesExternalSecretboth makeTURSO_DATABASE_URLmandatory, so the ephemeral file store is a local-development fallback only. GET /v1/eventsverifies every registered event from the chain and serves the verified facts (name, date, time, price, capacity, organizer) for the homepage cards. Events that fail verification are hidden.GET /v1/events/{covenant_id}verifies the event from the chain on each read: it fetches the genesis tx, follows input 0 to the KCC-23 preparation tx and decodes its metadata payload (legacy deploys carried it on the genesis tx), checks the maker (the spent output's owner pubkey), and verifies the address commitment (P2SH(blake3(redeem))reproduces the on-chain covenant output, which also recovers capacity). Events that fail verification are hidden.- Responses carry raw chain facts (
deploy_txid,authorizing_txid,metadata_txid,maker_address, decoded constants + state, payload) so any displayed value can be independently re-checked. - Deployed event facts are immutable (baked into the deploy tx), so verified
reads are memoized in-process (
VerifiedEventCache, no TTL) and the registry is re-verified in the background on boot (warm-up) — steady-state reads are instant, and the chain stays authoritative across restarts. - Availability (sold / tickets left) is derived from the chain only inside the buy flow to build transactions; read endpoints and the UI do not surface it.
- The frontend shows "Organized by: " as the trust anchor with a verified badge.
/events/{covenant_id} is both the buyer page and the link preview. The API
serves the built SPA shell with the verified event's title, description,
canonical URL, and Open Graph/Twitter tags injected. The metadata is built by a
pure function in packages/kit/src/event-meta.ts, shared with the client-side
navigation sync so a fresh load and an in-app navigation agree. Unknown,
unverified, or temporarily unreachable events still return 200 with the
generic site metadata — a preview never blocks the page or leaks an unverified
value. The chain read is bounded and uses the existing VerifiedEventCache.
/sitemap.xml lists verified event pages with absolute URLs, and /robots.txt
points at it while excluding check-in, ticket, and API routes. Both use the same
verification path as the events directory.
PUBLIC_ORIGIN supplies the absolute origin for these URLs. It is required in
production; local development falls back to AUTH_ORIGIN.
The app stays a thin orchestrator — timing and transport concerns are owned by the layers that already have the context to handle them:
- Reads (REST) go through the kaspa-client, which owns per-request timeouts and upstream retry/backoff. No app-level deadlines are imposed on the events directory; a slow or down chain surfaces to the UI as offline/retry.
- Broadcast (wRPC) goes through the vendored kaspa-wasm
RpcClient, which owns connect retry/reconnect/failover; only a connect-attempt bound is set. - Flow-specific confirmation waits (deploy "verifiable", buy "accepted by the
ledger") are owned by their flows via a shared, business-agnostic
pollUntilhelper — the data-access layer never knows why it is being polled.
- Node.js >= 20.19 (see
engines) for local development. - Rust, to build the covenant compiler — only needed to deploy events locally.
- Docker (with the Compose plugin) for the containerized run.
npm install
npm run devnpm run dev starts the API in watch mode and the Vite dev server, which
proxies /v1 to the API:
- Web SPA (Vite, hot reload):
http://localhost:5173 - API:
http://localhost:3000
Config is read from .env (shared with Compose) and .env.local (dev-only,
git-ignored); the real environment wins over both, and an empty value in
.env.local unsets a key — TURSO_DATABASE_URL= therefore forces the local
events.json / listings.json file store. Auth is fail-closed, so when
AUTH_SECRET is unset the runner generates an ephemeral one for the session and
sets AUTH_ORIGIN to the Vite origin automatically. Override the ports with
PORT / WEB_PORT.
Deploying events also needs the covenant compiler; build it once with:
npm run build:silvercRun one side only with npm run dev:api or npm run dev:web.
The app ships as one image; the API serves the built SPA.
cp example.env .env # then fill in TURSO_DATABASE_URL / TURSO_AUTH_TOKEN
docker compose up --buildThe API (which serves the built web SPA) listens on http://localhost:3000.
| Script | What it does |
|---|---|
npm run dev |
Runs the app locally: API (watch) + Vite dev server (no Docker). |
npm run dev:api |
Runs only the API in watch mode. |
npm run dev:web |
Runs only the Vite dev server. |
npm run build |
Builds every package (build script in each workspace). |
npm run build:silverc |
Builds the kticket-silverc covenant compiler (Rust, release). |
npm run typecheck |
Type-checks every workspace (tsc --noEmit). |
npm test |
Runs the test suite (Vitest). |
npm run validate:deploy |
Validates the delivery contract in deploy/ (image pin, probes, scrape, dashboard metric names). |
The API and the built SPA ship as one image. A push to main starts
.github/workflows/build-and-push.yaml, which:
- verifies the repository (typecheck, tests, covenant VM tests, committed artifact check, production build, manifest schema + delivery-contract validation),
- builds
linux/arm64and publishessha-<commit>+latestto GHCR, - smoke-tests the published image non-root with a read-only root filesystem,
- rewrites
deploy/deployment.yamlto the immutablesha-<commit>@sha256:<digest>reference and commits it.
Argo CD watches deploy/ on main and applies the result to the kticket
namespace. All application-owned manifests live under deploy/.
Runtime contract:
- Public Service —
kticket(ClusterIP, port 3000). The environment's routing layer reaches it directly; it serves the API and the SPA. - Metrics — the API also listens on the internal
METRICS_PORT(9090), exposed only through thekticket-metricsService and discovered by thekticketVMServiceScrape. A Grafana overview dashboard (kticket-dashboard) is delivered as a ConfigMap in theobservabilitynamespace. - Secrets -
deploy/externalsecret.yamlmaps the three required OCI Vault entries (k3s-01-kticket-turso-url,k3s-01-kticket-turso-token,k3s-01-kticket-auth-secret) through theoci-vaultClusterSecretStoreintokticket-secrets.deploy/feedback-externalsecret.yamlmaps the two optional feedback entries (k3s-01-kticket-telegram-feedback-bot-token,k3s-01-kticket-telegram-feedback-chat-id) into a separatekticket-feedback-secrets, so a missing feedback value cannot block boot. Those key names must match what is created in OCI Vault; the repository only ever stores the names, never the values. - Scaling — one replica. Wallet sign-in nonces and the in-process index mirrors are process-local, so horizontal scaling would need shared state first.
- Shutdown — a finalize can wait ~95s for chain confirmation, so the
process drains for up to 120s and the pod's
terminationGracePeriodSecondsis 150s.
Only testnet-10 is supported (HLD v0.23: mainnet is out of scope). The REST
host is api-tn10.kaspa.org.
Configuration comes from the compose .env (see example.env); when a value is
unset or invalid, every host falls back to testnet10.
- API reads
KASPANET(andPORT,HOST,METRICS_PORT,TLS_KEY,TLS_CERT,TURSO_DATABASE_URL,TURSO_AUTH_TOKEN,PUBLIC_ORIGIN,TELEGRAM_FEEDBACK_BOT_TOKEN,TELEGRAM_FEEDBACK_CHAT_ID) from the environment set bycompose.yaml. - Web loads the runtime identity from
GET /v1/configat boot, so one build works against whichever network the API serves.VITE_KASPANETis only a local-development fallback when that request fails. - Kit exposes the shared resolver (
getNetworkConfig) used by all hosts.
User-specific reads that carry no transaction signature (My Tickets, My Events)
require the caller to prove they control the wallet they claim. The model
mirrors Daftari: a one-time nonce challenge → the wallet signs a structured
message (kasware.signMessage) → the API verifies the Schnorr signature and
issues a short-lived JWT whose subject is the address → protected reads present
Authorization: Bearer <token>.
POST /v1/auth/challenge {address}→{ nonce, message }POST /v1/auth/session {message, signature}→{ token, expires_in_seconds }GET /v1/ticketsandGET /v1/events?organizer_address=require the token and are scoped to the authenticated address; other endpoints are unchanged (writes are verified by the chain at broadcast, reads of events/listings are public chain facts).
Auth is fail-closed: the API refuses to boot without AUTH_SECRET. The
nonce store is in-memory (single-instance deploy). Configuration:
AUTH_SECRET— required; the HS256 JWT signing secret.AUTH_ORIGIN— the origin the SPA is served from; the signed claim'sURImust match it (defaulthttp://localhost:3000).AUTH_SESSION_TTL_MS— JWT lifetime in ms (default 15 minutes).
POST /v1/feedback accepts { "message": "..." } from any visitor, with no
wallet and no account. The note is trimmed and capped at 1500 characters, then
written to the Turso feedback_outbox before delivery is attempted, so a
Telegram outage or a rollout never loses it. Delivery is optional:
TELEGRAM_FEEDBACK_BOT_TOKENandTELEGRAM_FEEDBACK_CHAT_IDenable delivery to a private chat. With either missing, the endpoint still accepts and queues.- The response is
202 { "accepted": true, "delivered": true|false }.delivered: falsemeans the note is queued for retry, not sent; the browser says that instead of claiming it was sent. - The route is rate-limited per client (five submissions per ten minutes) and
returns
429withretryAfterwhen the window is exhausted. - The Turso queue is required in deployment. Without
TURSO_DATABASE_URL, local development uses an in-memory queue that a restart drops.
If you like this repo, you can tip me at https://kas.coffee/danieliyahu.
