diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b438c14 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,167 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + SCARB_VERSION: 2.14.0 + SNFOUNDRY_VERSION: 0.62.1 + NODE_VERSION: 24 + PNPM_VERSION: 10 + +jobs: + contracts: + name: Cairo contracts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: software-mansion/setup-scarb@v1 + with: + scarb-version: ${{ env.SCARB_VERSION }} + scarb-lock: contracts/Scarb.lock + + - uses: foundry-rs/setup-snfoundry@v6 + with: + starknet-foundry-version: ${{ env.SNFOUNDRY_VERSION }} + + - name: Check formatting + working-directory: contracts + run: scarb fmt --check + + - name: Build + working-directory: contracts + run: scarb build + + - name: Test + working-directory: contracts + run: snforge test + + app: + name: Lint, types, build + runs-on: ubuntu-latest + + # The prerendered pages read from Postgres at build time, and the app talks + # to it over Neon's HTTP driver — hence the proxy in front of a plain + # container, mirroring the local setup documented in the README. + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: zicket + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + neon-proxy: + image: ghcr.io/timowilhelm/local-neon-http-proxy:main + env: + PG_CONNECTION_STRING: postgres://postgres:postgres@postgres:5432/zicket + ports: + - 4444:4444 + + env: + DATABASE_URL: postgres://postgres:postgres@localhost:5432/zicket + NEON_HTTP_ENDPOINT: http://localhost:4444/sql + + steps: + - uses: actions/checkout@v5 + + - uses: pnpm/action-setup@v6 + with: + version: ${{ env.PNPM_VERSION }} + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm exec tsc --noEmit + + - name: Apply migrations + # drizzle-kit uses the Neon WebSocket driver, which the HTTP proxy does + # not serve, so the SQL is applied straight over TCP instead. + run: | + for f in drizzle/*.sql; do + echo "applying $f" + psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q -f "$f" + done + + - name: Wait for the Neon HTTP proxy + # A bare POST to /sql is rejected without Neon's connection headers, so + # this waits on the port rather than on a response body. + run: | + for _ in $(seq 1 30); do + if (exec 3<>/dev/tcp/localhost/4444) 2>/dev/null; then + echo "proxy ready"; exit 0 + fi + sleep 2 + done + echo "proxy did not come up"; docker ps -a; exit 1 + + - name: Build + run: pnpm build + + chain: + name: Contract e2e on devnet + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: software-mansion/setup-scarb@v1 + with: + scarb-version: ${{ env.SCARB_VERSION }} + scarb-lock: contracts/Scarb.lock + + - uses: pnpm/action-setup@v6 + with: + version: ${{ env.PNPM_VERSION }} + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - run: pnpm install --frozen-lockfile + + # deploy.ts reads the Sierra and CASM artifacts, which are not committed. + - name: Build contracts + run: pnpm contracts:build + + - name: Start devnet + run: | + pnpm chain:devnet + for _ in $(seq 1 30); do + if curl -sf -o /dev/null http://localhost:5050/is_alive; then + echo "devnet ready"; exit 0 + fi + sleep 2 + done + echo "devnet did not come up"; docker logs zicket-devnet; exit 1 + + - name: Deploy + run: pnpm chain:deploy + + - name: End-to-end assertions + run: pnpm chain:e2e + + - if: failure() + run: docker logs zicket-devnet diff --git a/.gitignore b/.gitignore index ae3d869..8bb4659 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,7 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# cairo / scarb +contracts/target +contracts/.snfoundry_cache diff --git a/README.md b/README.md index f1699b4..8b40bc9 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,34 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Zicket -## Getting Started +[![CI](https://github.com/wheval/zicket/actions/workflows/ci.yml/badge.svg)](https://github.com/wheval/zicket/actions/workflows/ci.yml) + +Privacy-first event ticketing. **Host Freely. Attend Silently.** -First, run the development server: +Browse and buy tickets without handing over an identity: alongside ordinary +wallet-bound tickets, Zicket issues **anonymous tickets** that carry no owner +on-chain at all — just a commitment only the holder can open. + +The contracts are live on **Starknet Sepolia**; see +[On-chain layer](#on-chain-layer-starknet--cairo). + +## Getting Started ```bash -npm run dev -# or -yarn dev -# or +pnpm install pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Open [http://localhost:3000](http://localhost:3000). -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +The app runs without a chain configured — listings simply stay unpublished +until `NEXT_PUBLIC_ZICKET_CONTRACT_ADDRESS` is set. See +[Environment](#environment-1) for the full list. -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +| Area | Stack | +| --- | --- | +| App | Next.js 16, React 19, Tailwind v4 | +| Data | Drizzle ORM + Neon Postgres | +| Chain | Cairo contracts on Starknet, via starknet.js | ## Database (Drizzle + Neon) @@ -49,17 +59,179 @@ Seed your Neon DB from `lib/mock_data.ts`: - `pnpm db:seed` -## Learn More +The seeded events are rebased onto the current date so every demo listing is +still upcoming — a listing whose sale window has closed cannot be published +on-chain or bought. + +## On-chain layer (Starknet / Cairo) + +Ticketing runs on a pair of Cairo contracts in `contracts/`: + +| Contract | Role | +| --- | --- | +| `ZicketEvents` | Events, ticketing, escrow, refunds, check-in | +| `MockERC20` | Payment token for local development | + +### Two kinds of ticket + +- **Public** — bound to the buyer's address, one per wallet, transferable. +- **Anonymous** — the buyer computes `commitment = poseidon(secret, nullifier)` + in the browser and only the commitment reaches the chain. The ticket has no + owner. Check-in reveals the preimage and burns + `nullifier_hash = poseidon(nullifier)` so a ticket cannot be used twice. + + The secret never leaves the browser: it is written to `localStorage` *before* + the transaction is signed, because it is the only proof of ownership and + exists nowhere else. Losing it loses the ticket — by design. + +### Local end-to-end + +```bash +pnpm chain:devnet # starknet-devnet in Docker on :5050 +pnpm contracts:build # scarb build +pnpm contracts:test # snforge — 36 tests +pnpm chain:deploy # declare + deploy, writes deployments/ and .env.local +pnpm chain:e2e # 20 assertions straight against the contracts +pnpm chain:flow # 32 assertions through the running Next.js app +``` + +`pnpm chain:deploy` writes `NEXT_PUBLIC_ZICKET_CONTRACT_ADDRESS`, +`NEXT_PUBLIC_PAYMENT_TOKEN_ADDRESS` and the relayer credentials into +`.env.local`. Restarting devnet resets chain state, so re-run the deploy. + +### Sepolia — live deployment + +Deployed 2026-07-29. Canonical record: [`deployments/sepolia.json`](deployments/sepolia.json). + +| Contract | Address | Explorer | +| --- | --- | --- | +| `ZicketEvents` | `0x7b318fc47e158025e02cb41c6b6ae74d214e1d5b8c58241b09f8b0b452b8cbb` | [Voyager](https://sepolia.voyager.online/contract/0x7b318fc47e158025e02cb41c6b6ae74d214e1d5b8c58241b09f8b0b452b8cbb) | +| `ZUSD` payment token (18dp) | `0x785ab439b53aa6452deaa13a8bbaca43004284e6c3bebadf07f8ebb73333f15` | [Voyager](https://sepolia.voyager.online/contract/0x785ab439b53aa6452deaa13a8bbaca43004284e6c3bebadf07f8ebb73333f15) | + +| | | +| --- | --- | +| `ZicketEvents` class hash | `0x7389d6d0d3eb5d56d385941bb3562b74f386ef1cb04155662b6bb2a192e809d` | +| `ZUSD` class hash | `0x257e94e709dacfa2091a4ffa8fb8744d2a3c572429abf04d94202dc15681720` | +| Deployer / fee recipient | `0x6b4ff7d3d4ec97092b655ca7569598583e6e7c236ea37c23cc2664725687994` | +| Platform fee | 250 bps (2.5%) | +| RPC | `https://api.cartridge.gg/x/starknet/sepolia` | + +Point the app at it: + +```bash +NEXT_PUBLIC_STARKNET_NETWORK=sepolia +NEXT_PUBLIC_STARKNET_RPC_URL=https://api.cartridge.gg/x/starknet/sepolia +NEXT_PUBLIC_ZICKET_CONTRACT_ADDRESS=0x7b318fc47e158025e02cb41c6b6ae74d214e1d5b8c58241b09f8b0b452b8cbb +NEXT_PUBLIC_PAYMENT_TOKEN_ADDRESS=0x785ab439b53aa6452deaa13a8bbaca43004284e6c3bebadf07f8ebb73333f15 +``` + +starknet.js 8.x speaks JSON-RPC 0.9.0 — pick an endpoint that serves it. Cartridge +does; some public Sepolia endpoints have moved to 0.10.x or shut down entirely. + +Redeploying: + +```bash +NEXT_PUBLIC_STARKNET_NETWORK=sepolia \ +NEXT_PUBLIC_STARKNET_RPC_URL=https://api.cartridge.gg/x/starknet/sepolia \ +ZICKET_DEPLOY_MOCK_TOKEN=1 pnpm chain:deploy +``` + +`ZICKET_DEPLOY_MOCK_TOKEN=1` is required off devnet: deploying the mock ERC-20 is +opt-in so a real payment token cannot be replaced by accident. On Sepolia the mock +is deliberate — its `mint` is permissionless, so it doubles as a faucet and any +wallet can self-serve test funds: + +```bash +starkli invoke 0x785ab439b53aa6452deaa13a8bbaca43004284e6c3bebadf07f8ebb73333f15 \ + mint u256:1000000000000000000000 +``` + +Buying on Sepolia needs a real wallet (Argent X or Braavos) plus a little STRK for +fees; the devnet burner is not available off devnet. + +The e2e harnesses provision their own buyer wallets on non-devnet networks — +generated keys are cached in `.env.local` as `ZICKET_E2E_BUYER_KEYS`, funded with +STRK from the deployer and counterfactually deployed. Both suites pass against +live Sepolia (20 contract assertions, 32 full-stack). + +Event ids restart at 1 for every deployment, so a listing's `onchain_event_id` is +stored alongside the `onchain_contract_address` that issued it. Point the app at a +different deployment and previously published listings correctly read as +unpublished rather than resolving to some unrelated event. + +### Environment + +| Variable | Purpose | +| --- | --- | +| `NEXT_PUBLIC_STARKNET_NETWORK` | `devnet` \| `sepolia` \| `mainnet` | +| `NEXT_PUBLIC_STARKNET_RPC_URL` | RPC the browser reads from | +| `NEXT_PUBLIC_ZICKET_CONTRACT_ADDRESS` | Deployed `ZicketEvents` | +| `NEXT_PUBLIC_PAYMENT_TOKEN_ADDRESS` | ERC-20 used for payment | +| `NEXT_PUBLIC_TOKEN_USD_PRICE` | USD → token conversion for listed prices | +| `STARKNET_ADMIN_ADDRESS` / `STARKNET_ADMIN_PRIVATE_KEY` | Server relayer that publishes listings | + +Wallet connection lives on the ticket page: the purchase button becomes +**Connect Wallet** until a wallet is connected. On `devnet` that menu also offers +a predeployed burner account, so the flow can be exercised without a browser +extension. On Sepolia and mainnet a real wallet (Argent X or Braavos) is required. + +### API + +| Route | Purpose | +| --- | --- | +| `GET /api/chain/config` | Public chain configuration | +| `GET /api/chain/events/[id]` | On-chain state, including `saleOpen` | +| `POST /api/chain/events/[id]` | Publishes the listing via the relayer | +| `GET /api/chain/purchases?ticketId=` | Purchases recorded for a listing | +| `POST /api/chain/purchases` | Verifies a tx on-chain, then records it | + +`POST /api/chain/purchases` takes only a transaction hash; every other field is +read back from the receipt, so a client cannot fabricate a ticket. Anonymous +purchases are stored without a buyer address or email — recording either would +defeat the commitment scheme. + +### Running Postgres locally + +`@neondatabase/serverless` speaks HTTP, so a plain Postgres container needs a +proxy in front of it: + +```bash +docker run -d --name zicket-pg -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_USER=postgres -e POSTGRES_DB=zicket -p 5433:5432 postgres:16-alpine + +docker run -d --name zicket-neon-proxy -p 4444:4444 \ + -e PG_CONNECTION_STRING=postgres://postgres:postgres@host.docker.internal:5433/zicket \ + ghcr.io/timowilhelm/local-neon-http-proxy:main +``` + +Then set `NEON_HTTP_ENDPOINT=http://localhost:4444/sql` alongside +`DATABASE_URL`. Against a real Neon database, leave `NEON_HTTP_ENDPOINT` unset. + +## Scripts -To learn more about Next.js, take a look at the following resources: +| Command | What it does | +| --- | --- | +| `pnpm dev` / `pnpm build` / `pnpm start` | Next.js | +| `pnpm db:push` / `db:generate` / `db:migrate` / `db:seed` | Drizzle | +| `pnpm contracts:build` / `contracts:test` | scarb + snforge (36 tests) | +| `pnpm chain:devnet` | starknet-devnet in Docker on :5050 | +| `pnpm chain:deploy` | Declare + deploy, writes `deployments/` and `.env.local` | +| `pnpm chain:e2e` | 20 assertions straight against the contracts | +| `pnpm chain:flow` | 32 assertions through the running app | -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +## CI -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +[`.github/workflows/ci.yml`](.github/workflows/ci.yml) runs three jobs on every PR: -## Deploy on Vercel +| Job | Covers | +| --- | --- | +| `contracts` | `scarb fmt --check`, `scarb build`, `snforge test` | +| `app` | `eslint`, `tsc --noEmit`, `next build` against Postgres + the Neon HTTP proxy | +| `chain` | Boots devnet, deploys, runs the 20 contract assertions | -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +The build job needs a database because `/` and `/explore` are prerendered from +it. Migrations are applied with `psql` rather than `drizzle-kit`, which uses +Neon's WebSocket driver — the HTTP proxy only speaks HTTP. -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +`chain:flow` is not in CI: it drives a running server against a deployed chain, +which is covered well enough by the `app` and `chain` jobs separately. diff --git a/app/api/chain/config/route.ts b/app/api/chain/config/route.ts new file mode 100644 index 0000000..778ba0e --- /dev/null +++ b/app/api/chain/config/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; + +import { + PAYMENT_TOKEN_ADDRESS, + PAYMENT_TOKEN_DECIMALS, + PAYMENT_TOKEN_SYMBOL, + STARKNET_NETWORK, + STARKNET_RPC_URL, + ZICKET_CONTRACT_ADDRESS, + isChainConfigured, +} from "@/lib/starknet/config"; + +/** + * Public chain configuration, so the client can render the correct network and + * fail loudly when the contracts have not been deployed yet. + */ +export async function GET() { + return NextResponse.json({ + configured: isChainConfigured(), + network: STARKNET_NETWORK, + rpcUrl: STARKNET_RPC_URL, + contracts: { + zicket: ZICKET_CONTRACT_ADDRESS || null, + paymentToken: PAYMENT_TOKEN_ADDRESS || null, + }, + token: { + symbol: PAYMENT_TOKEN_SYMBOL, + decimals: PAYMENT_TOKEN_DECIMALS, + }, + }); +} diff --git a/app/api/chain/events/[id]/route.ts b/app/api/chain/events/[id]/route.ts new file mode 100644 index 0000000..e30e6a4 --- /dev/null +++ b/app/api/chain/events/[id]/route.ts @@ -0,0 +1,152 @@ +import { NextResponse } from "next/server"; + +import { CallData, cairo } from "starknet"; + +import { getTicketById, markTicketPublished } from "@/lib/db/queries"; +import { + isPublishedToCurrentDeployment, + usdToTokenUnits, + ZICKET_CONTRACT_ADDRESS, +} from "@/lib/starknet/config"; +import { metadataHashForTicket } from "@/lib/starknet/metadata"; +import { + eventIdFromReceipt, + getAdminAccount, + hasAdminAccount, +} from "@/lib/starknet/server"; +import { readEvent } from "@/lib/starknet/zicket"; + +type Props = { params: Promise<{ id: string }> }; + +/** Sales stay open for a day past the listed start time. */ +const SALE_WINDOW_SECONDS = 24 * 60 * 60; + +/** On-chain state for a catalogue listing. */ +export async function GET(_req: Request, { params }: Props) { + const { id } = await params; + + const ticket = await getTicketById(id); + if (!ticket) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + if (!isPublishedToCurrentDeployment(ticket)) { + return NextResponse.json({ published: false, ticketId: id }); + } + + try { + const event = await readEvent(ticket.onchain_event_id); + const now = Math.floor(Date.now() / 1000); + const soldOut = event.ticketsSold >= event.maxAttendees; + + return NextResponse.json({ + published: true, + ticketId: id, + contract: ZICKET_CONTRACT_ADDRESS, + event: { + ...event, + price: event.price.toString(), + escrow: event.escrow.toString(), + }, + // Mirrors the contract's own `_assert_sale_open` so the UI can disable + // the buy button instead of letting the transaction revert. + saleOpen: !event.cancelled && !soldOut && now < event.endTime, + soldOut, + ticketsRemaining: Math.max(event.maxAttendees - event.ticketsSold, 0), + metadataMatches: + ticket.metadata_hash != null && + BigInt(event.metadataHash) === BigInt(ticket.metadata_hash), + }); + } catch (error) { + return NextResponse.json( + { error: `Unable to read chain state: ${(error as Error).message}` }, + { status: 502 }, + ); + } +} + +/** + * Publishes the listing to the ZicketEvents contract. + * + * The platform account relays `create_event` so an organizer can list without + * holding a funded wallet. Idempotent: a listing that already has an + * `onchain_event_id` is returned untouched. + */ +export async function POST(_req: Request, { params }: Props) { + const { id } = await params; + + if (!hasAdminAccount()) { + return NextResponse.json( + { error: "Server relayer is not configured. Run `pnpm chain:deploy`." }, + { status: 503 }, + ); + } + + const ticket = await getTicketById(id); + if (!ticket) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + if (isPublishedToCurrentDeployment(ticket)) { + return NextResponse.json({ + published: true, + alreadyPublished: true, + onchainEventId: ticket.onchain_event_id, + }); + } + const price = ticket.paid ? usdToTokenUnits(ticket.price_in_usd) : 0n; + const metadataHash = metadataHashForTicket(ticket); + const startTime = ticket.event_date; + const endTime = startTime + SALE_WINDOW_SECONDS; + const capacity = Math.max(ticket.no_of_attendees * 2, 100); + + if (endTime <= Math.floor(Date.now() / 1000)) { + return NextResponse.json( + { error: "This event has already taken place, so it cannot be listed on-chain." }, + { status: 409 }, + ); + } + + try { + const admin = getAdminAccount(); + const { transaction_hash } = await admin.execute({ + contractAddress: ZICKET_CONTRACT_ADDRESS, + entrypoint: "create_event", + calldata: CallData.compile({ + metadata_hash: metadataHash, + price: cairo.uint256(price), + max_attendees: capacity, + start_time: startTime, + end_time: endTime, + anonymous_allowed: Boolean(ticket.anonymous), + }), + }); + + const onchainEventId = await eventIdFromReceipt(transaction_hash); + if (!onchainEventId) { + return NextResponse.json( + { error: "create_event did not emit an EventCreated event" }, + { status: 502 }, + ); + } + + await markTicketPublished({ + ticketId: id, + onchainEventId, + onchainContractAddress: ZICKET_CONTRACT_ADDRESS, + metadataHash, + organizerAddress: admin.address, + publishTxHash: transaction_hash, + }); + + return NextResponse.json({ + published: true, + onchainEventId, + metadataHash, + txHash: transaction_hash, + price: price.toString(), + capacity, + }); + } catch (error) { + return NextResponse.json( + { error: `Publish failed: ${(error as Error).message}` }, + { status: 502 }, + ); + } +} diff --git a/app/api/chain/purchases/route.ts b/app/api/chain/purchases/route.ts new file mode 100644 index 0000000..49bc67f --- /dev/null +++ b/app/api/chain/purchases/route.ts @@ -0,0 +1,117 @@ +import { NextResponse } from "next/server"; + +import { + getPurchasesForTicket, + getTicketById, + incrementAttendeeCount, + recordPurchase, +} from "@/lib/db/queries"; +import { isPublishedToCurrentDeployment } from "@/lib/starknet/config"; +import { verifyPurchaseTx } from "@/lib/starknet/server"; + +/** `GET /api/chain/purchases?ticketId=…` — purchases recorded for a listing. */ +export async function GET(req: Request) { + const ticketId = new URL(req.url).searchParams.get("ticketId"); + if (!ticketId) { + return NextResponse.json({ error: "ticketId is required" }, { status: 400 }); + } + + return NextResponse.json({ items: await getPurchasesForTicket(ticketId) }); +} + +interface PurchaseBody { + ticketId?: unknown; + txHash?: unknown; + mode?: unknown; + commitment?: unknown; + buyerAddress?: unknown; + email?: unknown; +} + +const TX_HASH_RE = /^0x[0-9a-fA-F]{1,64}$/; + +/** + * Records a purchase after verifying it on-chain. + * + * The client supplies only a transaction hash; every other field is taken from + * the receipt, so a caller cannot fabricate a ticket. For anonymous purchases + * the buyer address is discarded even if supplied — storing it would defeat the + * entire point of the commitment scheme. + */ +export async function POST(req: Request) { + let body: PurchaseBody; + try { + body = (await req.json()) as PurchaseBody; + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const ticketId = typeof body.ticketId === "string" ? body.ticketId.trim() : ""; + const txHash = typeof body.txHash === "string" ? body.txHash.trim() : ""; + + if (!ticketId) { + return NextResponse.json({ error: "ticketId is required" }, { status: 400 }); + } + if (!TX_HASH_RE.test(txHash)) { + return NextResponse.json({ error: "A valid txHash is required" }, { status: 400 }); + } + + const ticket = await getTicketById(ticketId); + if (!ticket) { + return NextResponse.json({ error: "Unknown ticket" }, { status: 404 }); + } + if (!isPublishedToCurrentDeployment(ticket)) { + return NextResponse.json( + { error: "This listing has not been published on-chain yet" }, + { status: 409 }, + ); + } + + let verified: Awaited>; + try { + verified = await verifyPurchaseTx(txHash); + } catch (error) { + return NextResponse.json( + { error: `Could not verify transaction: ${(error as Error).message}` }, + { status: 502 }, + ); + } + + if (!verified) { + return NextResponse.json( + { error: "Transaction did not produce a Zicket ticket purchase" }, + { status: 400 }, + ); + } + + if (verified.eventId !== ticket.onchain_event_id) { + return NextResponse.json( + { error: "Transaction belongs to a different event" }, + { status: 400 }, + ); + } + + const { purchase, created } = await recordPurchase({ + ticketId, + onchainEventId: verified.eventId, + onchainTicketId: verified.onchainTicketId, + mode: verified.mode, + commitment: verified.commitment, + // Deliberately null for anonymous purchases. + buyerAddress: verified.mode === "public" ? verified.buyer : null, + txHash, + status: "confirmed", + email: + verified.mode === "public" && typeof body.email === "string" && body.email.trim() + ? body.email.trim() + : null, + }); + + // Only a first sighting of this transaction moves the counter; a client + // retrying the same hash must not inflate the attendee total. + if (created) { + await incrementAttendeeCount(ticketId); + } + + return NextResponse.json({ purchase, created }, { status: created ? 201 : 200 }); +} diff --git a/app/api/tickets/[id]/route.ts b/app/api/tickets/[id]/route.ts index 61bca09..a525f86 100644 --- a/app/api/tickets/[id]/route.ts +++ b/app/api/tickets/[id]/route.ts @@ -38,6 +38,9 @@ export async function GET(_req: Request, { params }: Props) { paid: item.paid, price_in_usd: Number(item.priceInUsd), event_verified: item.eventVerified, + onchain_event_id: item.onchainEventId, + metadata_hash: item.metadataHash, + organizer_address: item.organizerAddress, }, }); } diff --git a/app/api/tickets/route.ts b/app/api/tickets/route.ts index 2ed5098..f9b3c56 100644 --- a/app/api/tickets/route.ts +++ b/app/api/tickets/route.ts @@ -20,6 +20,9 @@ function toApiTicket(row: typeof ticketsTable.$inferSelect) { paid: row.paid, price_in_usd: Number(row.priceInUsd), event_verified: row.eventVerified, + onchain_event_id: row.onchainEventId, + metadata_hash: row.metadataHash, + organizer_address: row.organizerAddress, }; } diff --git a/app/layout.tsx b/app/layout.tsx index 8dd5c60..60d66c7 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,6 +4,7 @@ import { Inter, Bricolage_Grotesque } from "next/font/google"; import "./globals.css"; import { Footer } from "@/components/web/footer"; import { Navbar } from "@/components/web/navbar"; +import { WalletProvider } from "@/components/web/wallet-provider"; const satoshi = localFont({ src: [ @@ -110,11 +111,13 @@ export default function RootLayout({ return ( - -
- {children} -
-
+ + +
+ {children} +
+
+
); diff --git a/components/web/connect-wallet-button.tsx b/components/web/connect-wallet-button.tsx new file mode 100644 index 0000000..d163117 --- /dev/null +++ b/components/web/connect-wallet-button.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { shortenAddress, useWallet } from "@/components/web/wallet-provider"; + +export function ConnectWalletButton({ className }: { className?: string }) { + const { status, address, wallets, burnerAvailable, error, connect, connectBurner, disconnect } = + useWallet(); + const [open, setOpen] = useState(false); + + if (status === "connected" && address) { + return ( + + + + + {shortenAddress(address)} + + + + + Connected wallet + + navigator.clipboard?.writeText(address)} + className="cursor-pointer text-sm" + > + Copy address + + + + Disconnect + + + + ); + } + + return ( + + + + + + + Choose a Starknet wallet + + + {wallets.length === 0 && ( +

+ No Starknet wallet detected. Install Argent X or Braavos. +

+ )} + + {wallets.map((wallet) => ( + void connect(wallet.id)} + className="cursor-pointer gap-2 text-sm capitalize" + > + {wallet.icon && ( + // eslint-disable-next-line @next/next/no-img-element + + )} + {wallet.name} + + ))} + + {burnerAvailable && ( + <> + + + Local development + + void connectBurner()} + className="cursor-pointer text-sm" + > + Use devnet burner account + + + )} + + {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/components/web/ticket-purchase-card.tsx b/components/web/ticket-purchase-card.tsx index 5780d1c..3516e44 100644 --- a/components/web/ticket-purchase-card.tsx +++ b/components/web/ticket-purchase-card.tsx @@ -12,6 +12,10 @@ import { SelectValue, } from "@/components/ui/select"; import type { Ticket } from "@/lib/types"; +import { useWallet } from "@/components/web/wallet-provider"; +import { ConnectWalletButton } from "@/components/web/connect-wallet-button"; +import { explorerTxUrl } from "@/lib/starknet/config"; +import { useTicketPurchase } from "@/lib/starknet/use-ticket-purchase"; import Image from "next/image"; type TicketType = { @@ -42,6 +46,29 @@ export function TicketPurchaseCard({ const [selectedType, setSelectedType] = useState(types[0]?.id ?? "standard"); const [email, setEmail] = useState(""); + const { status } = useWallet(); + const { purchase, stage, error, result, isBusy } = useTicketPurchase(ticket); + const isConnected = status === "connected"; + + const txUrl = result ? explorerTxUrl(result.txHash) : null; + + const buttonLabel = useMemo(() => { + switch (stage) { + case "preparing": + return "Preparing…"; + case "awaiting-signature": + return "Confirm in wallet…"; + case "confirming": + return "Confirming on-chain…"; + case "recording": + return "Finalising…"; + case "done": + return ticket.anonymous ? "Attending Anonymously" : "Ticket Secured"; + default: + return ticket.anonymous ? "Attend Anonymously" : "Get Ticket"; + } + }, [stage, ticket.anonymous]); + const privacyLabel = ticket.anonymous ? "Anonymous" : ticket.event_verified ? "Verified Access" : "Wallet"; return ( @@ -110,9 +137,48 @@ export function TicketPurchaseCard({ Secure & Instant Payment - + {isConnected ? ( + + ) : ( + + )} + + {stage === "error" && error && ( +

+ {error} +

+ )} + + {stage === "done" && result && ( +
+

+ {result.mode === "anonymous" + ? "You're in — anonymously. Your ticket secret is stored in this browser." + : "You're in. Your ticket is bound to your wallet."} +

+ {result.mode === "anonymous" && ( +

+ That secret is the only proof of your ticket, and Zicket never sees + it. Clearing site data will lose access to this event. +

+ )} + {txUrl ? ( + + View transaction + + ) : ( + {result.txHash} + )} +
+ )} ); diff --git a/components/web/wallet-provider.tsx b/components/web/wallet-provider.tsx new file mode 100644 index 0000000..edcf6c8 --- /dev/null +++ b/components/web/wallet-provider.tsx @@ -0,0 +1,196 @@ +"use client"; + +/** + * Wallet connection for Starknet, built directly on starknet.js `WalletAccount` + * and SNIP-1193 discovery (`window.starknet_*`). Deliberately dependency-free: + * the wallet-connector ecosystem lags starknet.js releases, and this app only + * needs connect / disconnect / execute. + * + * On devnet there is no browser extension, so a predeployed "burner" account is + * offered instead. That path is hard-gated to the devnet network so it can + * never be reached from a production build. + */ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; + +import { Account, RpcProvider, WalletAccount, type AccountInterface } from "starknet"; + +import { STARKNET_NETWORK, STARKNET_RPC_URL } from "@/lib/starknet/config"; + +/** Devnet account #1 (`starknet-devnet --seed 0`). Local development only. */ +const DEVNET_BURNER = { + address: "0x78662e7352d062084b0010068b99288486c2d8b914f6e2a55ce945f8792c8b1", + privateKey: "0xe1406455b7d66b1690803be066cbe5e", +}; + +const LAST_WALLET_KEY = "zicket:last-wallet"; + +export interface DiscoveredWallet { + id: string; + name: string; + icon?: string; +} + +type Status = "disconnected" | "connecting" | "connected"; + +interface WalletContextValue { + status: Status; + address: string | null; + account: AccountInterface | null; + wallets: DiscoveredWallet[]; + /** True when the burner shortcut is available (devnet only). */ + burnerAvailable: boolean; + error: string | null; + connect: (walletId: string) => Promise; + connectBurner: () => Promise; + disconnect: () => void; +} + +const WalletContext = createContext(null); + +interface InjectedWallet { + id?: string; + name?: string; + icon?: string | { light?: string; dark?: string }; + request?: (args: { type: string; params?: unknown }) => Promise; +} + +function discover(): Array<{ key: string; wallet: InjectedWallet }> { + if (typeof window === "undefined") return []; + + const found: Array<{ key: string; wallet: InjectedWallet }> = []; + for (const key of Object.keys(window)) { + if (!key.startsWith("starknet")) continue; + const candidate = (window as unknown as Record)[key]; + // SNIP-1193 wallets expose `request`; skip the legacy aggregate object. + if (candidate && typeof candidate.request === "function") { + found.push({ key, wallet: candidate }); + } + } + return found; +} + +function iconOf(wallet: InjectedWallet): string | undefined { + if (typeof wallet.icon === "string") return wallet.icon; + return wallet.icon?.light ?? wallet.icon?.dark; +} + +export function WalletProvider({ children }: { children: ReactNode }) { + const [status, setStatus] = useState("disconnected"); + const [address, setAddress] = useState(null); + const [account, setAccount] = useState(null); + const [wallets, setWallets] = useState([]); + const [error, setError] = useState(null); + + const burnerAvailable = STARKNET_NETWORK === "devnet"; + + useEffect(() => { + // Extensions inject asynchronously; re-scan briefly after mount. + const scan = () => + setWallets( + discover().map(({ key, wallet }) => ({ + id: key, + name: wallet.name ?? key.replace(/^starknet_?/, "") ?? key, + icon: iconOf(wallet), + })), + ); + + scan(); + const timers = [250, 750, 1500].map((delay) => window.setTimeout(scan, delay)); + return () => timers.forEach(window.clearTimeout); + }, []); + + const connect = useCallback(async (walletId: string) => { + setError(null); + setStatus("connecting"); + try { + const entry = discover().find(({ key }) => key === walletId); + if (!entry) throw new Error("That wallet is no longer available."); + + const walletAccount = await WalletAccount.connect( + { nodeUrl: STARKNET_RPC_URL }, + entry.wallet as never, + ); + + setAccount(walletAccount); + setAddress(walletAccount.address); + setStatus("connected"); + window.localStorage.setItem(LAST_WALLET_KEY, walletId); + } catch (cause) { + setStatus("disconnected"); + setError((cause as Error).message || "Could not connect to that wallet."); + } + }, []); + + const connectBurner = useCallback(async () => { + if (!burnerAvailable) return; + setError(null); + setStatus("connecting"); + try { + const provider = new RpcProvider({ nodeUrl: STARKNET_RPC_URL }); + const burner = new Account({ + provider, + address: DEVNET_BURNER.address, + signer: DEVNET_BURNER.privateKey, + }); + // Fail now rather than at signing time if the node isn't up. + await provider.getChainId(); + + setAccount(burner); + setAddress(burner.address); + setStatus("connected"); + } catch (cause) { + setStatus("disconnected"); + setError( + `Local devnet is unreachable at ${STARKNET_RPC_URL}. ` + + `Start it with \`pnpm chain:devnet\`. (${(cause as Error).message})`, + ); + } + }, [burnerAvailable]); + + const disconnect = useCallback(() => { + setAccount(null); + setAddress(null); + setStatus("disconnected"); + setError(null); + window.localStorage.removeItem(LAST_WALLET_KEY); + }, []); + + const value = useMemo( + () => ({ + status, + address, + account, + wallets, + burnerAvailable, + error, + connect, + connectBurner, + disconnect, + }), + [status, address, account, wallets, burnerAvailable, error, connect, connectBurner, disconnect], + ); + + return {children}; +} + +export function useWallet(): WalletContextValue { + const context = useContext(WalletContext); + if (!context) { + throw new Error("useWallet must be used inside ."); + } + return context; +} + +export function shortenAddress(value: string, size = 4): string { + const normalized = value.startsWith("0x") ? value : `0x${value}`; + if (normalized.length <= size * 2 + 2) return normalized; + return `${normalized.slice(0, size + 2)}…${normalized.slice(-size)}`; +} diff --git a/contracts/Scarb.lock b/contracts/Scarb.lock new file mode 100644 index 0000000..1e33abd --- /dev/null +++ b/contracts/Scarb.lock @@ -0,0 +1,24 @@ +# Code generated by scarb DO NOT EDIT. +version = 1 + +[[package]] +name = "snforge_scarb_plugin" +version = "0.62.1" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:f029f266be8fd2e66339be3e39e1cdada308d49cc12c2f76f3f5e949668361da" + +[[package]] +name = "snforge_std" +version = "0.62.1" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:20ef99a8f3d6515ec609499334f9b2fe86ddf9264b84156ff068f40b28994bc8" +dependencies = [ + "snforge_scarb_plugin", +] + +[[package]] +name = "zicket" +version = "0.1.0" +dependencies = [ + "snforge_std", +] diff --git a/contracts/Scarb.toml b/contracts/Scarb.toml new file mode 100644 index 0000000..2d227d6 --- /dev/null +++ b/contracts/Scarb.toml @@ -0,0 +1,21 @@ +[package] +name = "zicket" +version = "0.1.0" +edition = "2024_07" + +[dependencies] +starknet = "2.14.0" + +[dev-dependencies] +snforge_std = "0.62.1" +assert_macros = "2.14.0" + +[[target.starknet-contract]] +sierra = true +casm = true + +[scripts] +test = "snforge test" + +[tool.scarb] +allow-prebuilt-plugins = ["snforge_std"] diff --git a/contracts/src/interfaces.cairo b/contracts/src/interfaces.cairo new file mode 100644 index 0000000..d6f88d2 --- /dev/null +++ b/contracts/src/interfaces.cairo @@ -0,0 +1,71 @@ +//! Public interfaces for the Zicket protocol. + +use starknet::ContractAddress; +use zicket::types::{EventData, TicketData}; + +/// Minimal ERC20 surface required for ticket settlement. +#[starknet::interface] +pub trait IERC20 { + fn balance_of(self: @TContractState, account: ContractAddress) -> u256; + fn allowance(self: @TContractState, owner: ContractAddress, spender: ContractAddress) -> u256; + fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool; + fn transfer_from( + ref self: TContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256, + ) -> bool; + fn approve(ref self: TContractState, spender: ContractAddress, amount: u256) -> bool; +} + +/// Core ticketing interface. +#[starknet::interface] +pub trait IZicketEvents { + // ── Organizer ── + fn create_event( + ref self: TContractState, + metadata_hash: felt252, + price: u256, + max_attendees: u32, + start_time: u64, + end_time: u64, + anonymous_allowed: bool, + ) -> u64; + fn cancel_event(ref self: TContractState, event_id: u64); + fn withdraw(ref self: TContractState, event_id: u64) -> u256; + + // ── Attendee ── + fn buy_ticket(ref self: TContractState, event_id: u64) -> u64; + fn buy_ticket_anonymous(ref self: TContractState, event_id: u64, commitment: felt252) -> u64; + fn transfer_ticket(ref self: TContractState, ticket_id: u64, to: ContractAddress); + fn check_in(ref self: TContractState, ticket_id: u64); + fn check_in_anonymous( + ref self: TContractState, event_id: u64, secret: felt252, nullifier: felt252, + ) -> u64; + fn refund(ref self: TContractState, ticket_id: u64) -> u256; + fn refund_anonymous( + ref self: TContractState, + event_id: u64, + secret: felt252, + nullifier: felt252, + recipient: ContractAddress, + ) -> u256; + + // ── Views ── + fn get_event(self: @TContractState, event_id: u64) -> EventData; + fn get_ticket(self: @TContractState, ticket_id: u64) -> TicketData; + fn ticket_of(self: @TContractState, event_id: u64, attendee: ContractAddress) -> u64; + fn ticket_of_commitment(self: @TContractState, event_id: u64, commitment: felt252) -> u64; + fn is_nullifier_used(self: @TContractState, event_id: u64, nullifier_hash: felt252) -> bool; + fn tickets_remaining(self: @TContractState, event_id: u64) -> u32; + fn events_count(self: @TContractState) -> u64; + fn tickets_count(self: @TContractState) -> u64; + fn compute_commitment(self: @TContractState, secret: felt252, nullifier: felt252) -> felt252; + fn compute_nullifier_hash(self: @TContractState, nullifier: felt252) -> felt252; + + // ── Admin ── + fn payment_token(self: @TContractState) -> ContractAddress; + fn platform_fee_bps(self: @TContractState) -> u16; + fn fee_recipient(self: @TContractState) -> ContractAddress; + fn owner(self: @TContractState) -> ContractAddress; + fn set_platform_fee_bps(ref self: TContractState, bps: u16); + fn set_fee_recipient(ref self: TContractState, recipient: ContractAddress); + fn transfer_ownership(ref self: TContractState, new_owner: ContractAddress); +} diff --git a/contracts/src/lib.cairo b/contracts/src/lib.cairo new file mode 100644 index 0000000..05d1b18 --- /dev/null +++ b/contracts/src/lib.cairo @@ -0,0 +1,4 @@ +pub mod interfaces; +pub mod mock_erc20; +pub mod types; +pub mod zicket_events; diff --git a/contracts/src/mock_erc20.cairo b/contracts/src/mock_erc20.cairo new file mode 100644 index 0000000..4d34132 --- /dev/null +++ b/contracts/src/mock_erc20.cairo @@ -0,0 +1,170 @@ +//! Minimal ERC20 used as the settlement asset on devnet and in tests. +//! +//! On mainnet/sepolia the protocol is configured with the canonical STRK or +//! USDC address instead — this contract exists so the full purchase flow can be +//! exercised locally without bridging. + +#[starknet::contract] +pub mod MockERC20 { + use core::num::traits::Zero; + use starknet::storage::*; + use starknet::{ContractAddress, get_caller_address}; + use zicket::interfaces::IERC20; + + pub mod Errors { + pub const INSUFFICIENT_BALANCE: felt252 = 'ERC20: insufficient balance'; + pub const INSUFFICIENT_ALLOWANCE: felt252 = 'ERC20: insufficient allowance'; + pub const ZERO_ADDRESS: felt252 = 'ERC20: zero address'; + } + + #[storage] + pub struct Storage { + name: ByteArray, + symbol: ByteArray, + decimals: u8, + total_supply: u256, + balances: Map, + allowances: Map>, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + Transfer: Transfer, + Approval: Approval, + } + + #[derive(Drop, starknet::Event)] + pub struct Transfer { + #[key] + pub from: ContractAddress, + #[key] + pub to: ContractAddress, + pub value: u256, + } + + #[derive(Drop, starknet::Event)] + pub struct Approval { + #[key] + pub owner: ContractAddress, + #[key] + pub spender: ContractAddress, + pub value: u256, + } + + #[constructor] + fn constructor( + ref self: ContractState, + name: ByteArray, + symbol: ByteArray, + decimals: u8, + initial_supply: u256, + recipient: ContractAddress, + ) { + self.name.write(name); + self.symbol.write(symbol); + self.decimals.write(decimals); + if initial_supply > 0 { + self._mint(recipient, initial_supply); + } + } + + #[abi(embed_v0)] + pub impl MockERC20Impl of IERC20 { + fn balance_of(self: @ContractState, account: ContractAddress) -> u256 { + self.balances.entry(account).read() + } + + fn allowance( + self: @ContractState, owner: ContractAddress, spender: ContractAddress, + ) -> u256 { + self.allowances.entry(owner).entry(spender).read() + } + + fn transfer(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool { + self._transfer(get_caller_address(), recipient, amount); + true + } + + fn transfer_from( + ref self: ContractState, + sender: ContractAddress, + recipient: ContractAddress, + amount: u256, + ) -> bool { + let spender = get_caller_address(); + let allowed = self.allowances.entry(sender).entry(spender).read(); + assert(allowed >= amount, Errors::INSUFFICIENT_ALLOWANCE); + self.allowances.entry(sender).entry(spender).write(allowed - amount); + self._transfer(sender, recipient, amount); + true + } + + fn approve(ref self: ContractState, spender: ContractAddress, amount: u256) -> bool { + let owner = get_caller_address(); + self.allowances.entry(owner).entry(spender).write(amount); + self.emit(Event::Approval(Approval { owner, spender, value: amount })); + true + } + } + + #[starknet::interface] + pub trait IMockERC20Meta { + fn name(self: @TContractState) -> ByteArray; + fn symbol(self: @TContractState) -> ByteArray; + fn decimals(self: @TContractState) -> u8; + fn total_supply(self: @TContractState) -> u256; + /// Unrestricted faucet — devnet/testing only. + fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256); + } + + #[abi(embed_v0)] + pub impl MockERC20MetaImpl of IMockERC20Meta { + fn name(self: @ContractState) -> ByteArray { + self.name.read() + } + + fn symbol(self: @ContractState) -> ByteArray { + self.symbol.read() + } + + fn decimals(self: @ContractState) -> u8 { + self.decimals.read() + } + + fn total_supply(self: @ContractState) -> u256 { + self.total_supply.read() + } + + fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) { + self._mint(recipient, amount); + } + } + + #[generate_trait] + impl InternalImpl of InternalTrait { + fn _mint(ref self: ContractState, recipient: ContractAddress, amount: u256) { + assert(recipient.is_non_zero(), Errors::ZERO_ADDRESS); + self.total_supply.write(self.total_supply.read() + amount); + self.balances.entry(recipient).write(self.balances.entry(recipient).read() + amount); + self + .emit( + Event::Transfer(Transfer { from: Zero::zero(), to: recipient, value: amount }), + ); + } + + fn _transfer( + ref self: ContractState, + sender: ContractAddress, + recipient: ContractAddress, + amount: u256, + ) { + assert(recipient.is_non_zero(), Errors::ZERO_ADDRESS); + let sender_balance = self.balances.entry(sender).read(); + assert(sender_balance >= amount, Errors::INSUFFICIENT_BALANCE); + self.balances.entry(sender).write(sender_balance - amount); + self.balances.entry(recipient).write(self.balances.entry(recipient).read() + amount); + self.emit(Event::Transfer(Transfer { from: sender, to: recipient, value: amount })); + } + } +} diff --git a/contracts/src/types.cairo b/contracts/src/types.cairo new file mode 100644 index 0000000..b8cffb6 --- /dev/null +++ b/contracts/src/types.cairo @@ -0,0 +1,53 @@ +//! Shared data types for the Zicket ticketing protocol. + +use starknet::ContractAddress; + +/// Ticket privacy mode. +/// +/// * `Public` — ticket is bound to a wallet address and is transferable. +/// * `Anonymous` — ticket is bound to a Poseidon commitment. No address is +/// stored, so a relayer may purchase on behalf of an attendee and any wallet +/// holding the secret can perform the check-in. +#[derive(Copy, Drop, Serde, PartialEq, starknet::Store)] +pub enum TicketMode { + #[default] + Public, + Anonymous, +} + +/// On-chain representation of an event. +/// +/// `metadata_hash` is a commitment to the off-chain metadata (title, image, +/// location, description) stored in the Zicket database, so the indexer can +/// prove the rendered content matches what the organizer published. +#[derive(Copy, Drop, Serde, starknet::Store)] +pub struct EventData { + pub organizer: ContractAddress, + pub metadata_hash: felt252, + pub price: u256, + pub max_attendees: u32, + pub tickets_sold: u32, + pub start_time: u64, + pub end_time: u64, + pub anonymous_allowed: bool, + pub cancelled: bool, + pub escrow: u256, + pub withdrawn: bool, +} + +/// On-chain representation of a single ticket. +/// +/// For `TicketMode::Anonymous` tickets `owner` is the zero address and +/// `commitment` holds `poseidon(secret, nullifier)`. For `TicketMode::Public` +/// tickets `commitment` is `0` and `owner` is the holding wallet. +#[derive(Copy, Drop, Serde, starknet::Store)] +pub struct TicketData { + pub event_id: u64, + pub owner: ContractAddress, + pub commitment: felt252, + pub mode: TicketMode, + pub paid: u256, + pub purchased_at: u64, + pub checked_in: bool, + pub refunded: bool, +} diff --git a/contracts/src/zicket_events.cairo b/contracts/src/zicket_events.cairo new file mode 100644 index 0000000..e335034 --- /dev/null +++ b/contracts/src/zicket_events.cairo @@ -0,0 +1,737 @@ +//! # ZicketEvents +//! +//! Privacy-first event ticketing for Starknet. +//! +//! ## Ticket modes +//! +//! * **Public** — the ticket is bound to a wallet address, transferable, and +//! checked in by the holder or the organizer. +//! * **Anonymous** — the ticket is bound to `poseidon(secret, nullifier)`. No +//! address is recorded against the ticket, so a relayer may purchase on behalf +//! of an attendee and *any* wallet holding the secret can check in. Check-in +//! burns `poseidon(nullifier)` so a ticket cannot be used twice. +//! +//! ## Settlement +//! +//! Ticket sales are escrowed per event. After the event ends the organizer calls +//! `withdraw`, which pays `platform_fee_bps` to the fee recipient and the +//! remainder to the organizer. If the organizer cancels, attendees reclaim their +//! funds with `refund` / `refund_anonymous`. + +#[starknet::contract] +pub mod ZicketEvents { + use core::hash::HashStateTrait; + use core::num::traits::Zero; + use core::poseidon::PoseidonTrait; + use starknet::storage::*; + use starknet::{ContractAddress, get_block_timestamp, get_caller_address, get_contract_address}; + use zicket::interfaces::{IERC20Dispatcher, IERC20DispatcherTrait, IZicketEvents}; + use zicket::types::{EventData, TicketData, TicketMode}; + + /// Basis-point denominator. + const BPS_DENOMINATOR: u256 = 10000; + /// Hard cap on the configurable platform fee (10%). + const MAX_FEE_BPS: u16 = 1000; + + pub mod Errors { + pub const NOT_OWNER: felt252 = 'Zicket: not owner'; + pub const NOT_ORGANIZER: felt252 = 'Zicket: not organizer'; + pub const EVENT_NOT_FOUND: felt252 = 'Zicket: event not found'; + pub const TICKET_NOT_FOUND: felt252 = 'Zicket: ticket not found'; + pub const EVENT_CANCELLED: felt252 = 'Zicket: event cancelled'; + pub const EVENT_NOT_CANCELLED: felt252 = 'Zicket: event not cancelled'; + pub const SOLD_OUT: felt252 = 'Zicket: sold out'; + pub const SALE_CLOSED: felt252 = 'Zicket: sale closed'; + pub const ALREADY_HAS_TICKET: felt252 = 'Zicket: already has ticket'; + pub const ANON_NOT_ALLOWED: felt252 = 'Zicket: anon not allowed'; + pub const COMMITMENT_USED: felt252 = 'Zicket: commitment used'; + pub const INVALID_COMMITMENT: felt252 = 'Zicket: invalid commitment'; + pub const NULLIFIER_USED: felt252 = 'Zicket: nullifier used'; + pub const ALREADY_CHECKED_IN: felt252 = 'Zicket: already checked in'; + pub const NOT_TICKET_OWNER: felt252 = 'Zicket: not ticket owner'; + pub const NOT_PUBLIC_TICKET: felt252 = 'Zicket: not public ticket'; + pub const EVENT_NOT_ENDED: felt252 = 'Zicket: event not ended'; + pub const ALREADY_WITHDRAWN: felt252 = 'Zicket: already withdrawn'; + pub const ALREADY_REFUNDED: felt252 = 'Zicket: already refunded'; + pub const INVALID_CAPACITY: felt252 = 'Zicket: invalid capacity'; + pub const INVALID_WINDOW: felt252 = 'Zicket: invalid time window'; + pub const FEE_TOO_HIGH: felt252 = 'Zicket: fee too high'; + pub const ZERO_ADDRESS: felt252 = 'Zicket: zero address'; + pub const PAYMENT_FAILED: felt252 = 'Zicket: payment failed'; + } + + #[storage] + pub struct Storage { + owner: ContractAddress, + payment_token: ContractAddress, + fee_recipient: ContractAddress, + platform_fee_bps: u16, + events_count: u64, + tickets_count: u64, + events: Map, + tickets: Map, + /// event_id -> attendee -> ticket_id (0 when absent) + ticket_by_attendee: Map>, + /// event_id -> commitment -> ticket_id (0 when absent) + ticket_by_commitment: Map>, + /// event_id -> poseidon(nullifier) -> spent + nullifier_used: Map>, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + EventCreated: EventCreated, + EventCancelled: EventCancelled, + TicketPurchased: TicketPurchased, + AnonymousTicketPurchased: AnonymousTicketPurchased, + TicketTransferred: TicketTransferred, + CheckedIn: CheckedIn, + Refunded: Refunded, + Withdrawn: Withdrawn, + PlatformFeeUpdated: PlatformFeeUpdated, + FeeRecipientUpdated: FeeRecipientUpdated, + OwnershipTransferred: OwnershipTransferred, + } + + #[derive(Drop, starknet::Event)] + pub struct EventCreated { + #[key] + pub event_id: u64, + #[key] + pub organizer: ContractAddress, + pub metadata_hash: felt252, + pub price: u256, + pub max_attendees: u32, + pub start_time: u64, + pub end_time: u64, + pub anonymous_allowed: bool, + } + + #[derive(Drop, starknet::Event)] + pub struct EventCancelled { + #[key] + pub event_id: u64, + pub cancelled_at: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct TicketPurchased { + #[key] + pub event_id: u64, + #[key] + pub ticket_id: u64, + #[key] + pub buyer: ContractAddress, + pub price: u256, + pub purchased_at: u64, + } + + /// Deliberately omits the buyer address: only the commitment is published. + #[derive(Drop, starknet::Event)] + pub struct AnonymousTicketPurchased { + #[key] + pub event_id: u64, + #[key] + pub ticket_id: u64, + pub commitment: felt252, + pub price: u256, + pub purchased_at: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct TicketTransferred { + #[key] + pub ticket_id: u64, + #[key] + pub from: ContractAddress, + #[key] + pub to: ContractAddress, + } + + #[derive(Drop, starknet::Event)] + pub struct CheckedIn { + #[key] + pub event_id: u64, + #[key] + pub ticket_id: u64, + pub anonymous: bool, + pub nullifier_hash: felt252, + pub checked_in_at: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct Refunded { + #[key] + pub event_id: u64, + #[key] + pub ticket_id: u64, + pub recipient: ContractAddress, + pub amount: u256, + } + + #[derive(Drop, starknet::Event)] + pub struct Withdrawn { + #[key] + pub event_id: u64, + #[key] + pub organizer: ContractAddress, + pub organizer_amount: u256, + pub fee_amount: u256, + } + + #[derive(Drop, starknet::Event)] + pub struct PlatformFeeUpdated { + pub old_bps: u16, + pub new_bps: u16, + } + + #[derive(Drop, starknet::Event)] + pub struct FeeRecipientUpdated { + pub old_recipient: ContractAddress, + pub new_recipient: ContractAddress, + } + + #[derive(Drop, starknet::Event)] + pub struct OwnershipTransferred { + #[key] + pub previous_owner: ContractAddress, + #[key] + pub new_owner: ContractAddress, + } + + #[constructor] + fn constructor( + ref self: ContractState, + owner: ContractAddress, + payment_token: ContractAddress, + fee_recipient: ContractAddress, + platform_fee_bps: u16, + ) { + assert(owner.is_non_zero(), Errors::ZERO_ADDRESS); + assert(fee_recipient.is_non_zero(), Errors::ZERO_ADDRESS); + assert(platform_fee_bps <= MAX_FEE_BPS, Errors::FEE_TOO_HIGH); + + self.owner.write(owner); + self.payment_token.write(payment_token); + self.fee_recipient.write(fee_recipient); + self.platform_fee_bps.write(platform_fee_bps); + } + + #[abi(embed_v0)] + pub impl ZicketEventsImpl of IZicketEvents { + // ── Organizer ── + fn create_event( + ref self: ContractState, + metadata_hash: felt252, + price: u256, + max_attendees: u32, + start_time: u64, + end_time: u64, + anonymous_allowed: bool, + ) -> u64 { + assert(max_attendees > 0, Errors::INVALID_CAPACITY); + assert(end_time > start_time, Errors::INVALID_WINDOW); + + let organizer = get_caller_address(); + let event_id = self.events_count.read() + 1; + + self + .events + .entry(event_id) + .write( + EventData { + organizer, + metadata_hash, + price, + max_attendees, + tickets_sold: 0, + start_time, + end_time, + anonymous_allowed, + cancelled: false, + escrow: 0, + withdrawn: false, + }, + ); + self.events_count.write(event_id); + + self + .emit( + Event::EventCreated( + EventCreated { + event_id, + organizer, + metadata_hash, + price, + max_attendees, + start_time, + end_time, + anonymous_allowed, + }, + ), + ); + + event_id + } + + fn cancel_event(ref self: ContractState, event_id: u64) { + let mut event = self._load_event(event_id); + assert(event.organizer == get_caller_address(), Errors::NOT_ORGANIZER); + assert(!event.cancelled, Errors::EVENT_CANCELLED); + assert(!event.withdrawn, Errors::ALREADY_WITHDRAWN); + + event.cancelled = true; + self.events.entry(event_id).write(event); + + self + .emit( + Event::EventCancelled( + EventCancelled { event_id, cancelled_at: get_block_timestamp() }, + ), + ); + } + + fn withdraw(ref self: ContractState, event_id: u64) -> u256 { + let mut event = self._load_event(event_id); + let caller = get_caller_address(); + + assert(event.organizer == caller, Errors::NOT_ORGANIZER); + assert(!event.cancelled, Errors::EVENT_CANCELLED); + assert(!event.withdrawn, Errors::ALREADY_WITHDRAWN); + assert(get_block_timestamp() >= event.end_time, Errors::EVENT_NOT_ENDED); + + let gross = event.escrow; + event.escrow = 0; + event.withdrawn = true; + self.events.entry(event_id).write(event); + + let fee = gross * self.platform_fee_bps.read().into() / BPS_DENOMINATOR; + let payout = gross - fee; + + if gross > 0 { + let token = IERC20Dispatcher { contract_address: self.payment_token.read() }; + if fee > 0 { + assert(token.transfer(self.fee_recipient.read(), fee), Errors::PAYMENT_FAILED); + } + if payout > 0 { + assert(token.transfer(caller, payout), Errors::PAYMENT_FAILED); + } + } + + self + .emit( + Event::Withdrawn( + Withdrawn { + event_id, organizer: caller, organizer_amount: payout, fee_amount: fee, + }, + ), + ); + + payout + } + + // ── Attendee ── + fn buy_ticket(ref self: ContractState, event_id: u64) -> u64 { + let mut event = self._load_event(event_id); + let buyer = get_caller_address(); + + self._assert_sale_open(@event); + assert( + self.ticket_by_attendee.entry(event_id).entry(buyer).read() == 0, + Errors::ALREADY_HAS_TICKET, + ); + + self._collect_payment(buyer, event.price); + + let ticket_id = self.tickets_count.read() + 1; + let now = get_block_timestamp(); + + self + .tickets + .entry(ticket_id) + .write( + TicketData { + event_id, + owner: buyer, + commitment: 0, + mode: TicketMode::Public, + paid: event.price, + purchased_at: now, + checked_in: false, + refunded: false, + }, + ); + self.tickets_count.write(ticket_id); + self.ticket_by_attendee.entry(event_id).entry(buyer).write(ticket_id); + + event.tickets_sold += 1; + event.escrow += event.price; + self.events.entry(event_id).write(event); + + self + .emit( + Event::TicketPurchased( + TicketPurchased { + event_id, ticket_id, buyer, price: event.price, purchased_at: now, + }, + ), + ); + + ticket_id + } + + fn buy_ticket_anonymous( + ref self: ContractState, event_id: u64, commitment: felt252, + ) -> u64 { + let mut event = self._load_event(event_id); + + self._assert_sale_open(@event); + assert(event.anonymous_allowed, Errors::ANON_NOT_ALLOWED); + assert(commitment != 0, Errors::INVALID_COMMITMENT); + assert( + self.ticket_by_commitment.entry(event_id).entry(commitment).read() == 0, + Errors::COMMITMENT_USED, + ); + + // The payer is charged but is never recorded against the ticket, so a + // relayer can settle on behalf of the attendee. + self._collect_payment(get_caller_address(), event.price); + + let ticket_id = self.tickets_count.read() + 1; + let now = get_block_timestamp(); + + self + .tickets + .entry(ticket_id) + .write( + TicketData { + event_id, + owner: Zero::zero(), + commitment, + mode: TicketMode::Anonymous, + paid: event.price, + purchased_at: now, + checked_in: false, + refunded: false, + }, + ); + self.tickets_count.write(ticket_id); + self.ticket_by_commitment.entry(event_id).entry(commitment).write(ticket_id); + + event.tickets_sold += 1; + event.escrow += event.price; + self.events.entry(event_id).write(event); + + self + .emit( + Event::AnonymousTicketPurchased( + AnonymousTicketPurchased { + event_id, ticket_id, commitment, price: event.price, purchased_at: now, + }, + ), + ); + + ticket_id + } + + fn transfer_ticket(ref self: ContractState, ticket_id: u64, to: ContractAddress) { + let mut ticket = self._load_ticket(ticket_id); + let caller = get_caller_address(); + + assert(ticket.mode == TicketMode::Public, Errors::NOT_PUBLIC_TICKET); + assert(ticket.owner == caller, Errors::NOT_TICKET_OWNER); + assert(to.is_non_zero(), Errors::ZERO_ADDRESS); + assert(!ticket.checked_in, Errors::ALREADY_CHECKED_IN); + assert( + self.ticket_by_attendee.entry(ticket.event_id).entry(to).read() == 0, + Errors::ALREADY_HAS_TICKET, + ); + + let event = self._load_event(ticket.event_id); + assert(!event.cancelled, Errors::EVENT_CANCELLED); + + ticket.owner = to; + self.tickets.entry(ticket_id).write(ticket); + self.ticket_by_attendee.entry(ticket.event_id).entry(caller).write(0); + self.ticket_by_attendee.entry(ticket.event_id).entry(to).write(ticket_id); + + self.emit(Event::TicketTransferred(TicketTransferred { ticket_id, from: caller, to })); + } + + fn check_in(ref self: ContractState, ticket_id: u64) { + let mut ticket = self._load_ticket(ticket_id); + let event = self._load_event(ticket.event_id); + let caller = get_caller_address(); + + assert(ticket.mode == TicketMode::Public, Errors::NOT_PUBLIC_TICKET); + assert(ticket.owner == caller || event.organizer == caller, Errors::NOT_TICKET_OWNER); + assert(!event.cancelled, Errors::EVENT_CANCELLED); + assert(!ticket.checked_in, Errors::ALREADY_CHECKED_IN); + + ticket.checked_in = true; + self.tickets.entry(ticket_id).write(ticket); + + self + .emit( + Event::CheckedIn( + CheckedIn { + event_id: ticket.event_id, + ticket_id, + anonymous: false, + nullifier_hash: 0, + checked_in_at: get_block_timestamp(), + }, + ), + ); + } + + fn check_in_anonymous( + ref self: ContractState, event_id: u64, secret: felt252, nullifier: felt252, + ) -> u64 { + let event = self._load_event(event_id); + assert(!event.cancelled, Errors::EVENT_CANCELLED); + + let commitment = self._commitment(secret, nullifier); + let ticket_id = self.ticket_by_commitment.entry(event_id).entry(commitment).read(); + assert(ticket_id != 0, Errors::TICKET_NOT_FOUND); + + let nullifier_hash = self._nullifier_hash(nullifier); + assert( + !self.nullifier_used.entry(event_id).entry(nullifier_hash).read(), + Errors::NULLIFIER_USED, + ); + + let mut ticket = self.tickets.entry(ticket_id).read(); + assert(!ticket.checked_in, Errors::ALREADY_CHECKED_IN); + + ticket.checked_in = true; + self.tickets.entry(ticket_id).write(ticket); + self.nullifier_used.entry(event_id).entry(nullifier_hash).write(true); + + self + .emit( + Event::CheckedIn( + CheckedIn { + event_id, + ticket_id, + anonymous: true, + nullifier_hash, + checked_in_at: get_block_timestamp(), + }, + ), + ); + + ticket_id + } + + fn refund(ref self: ContractState, ticket_id: u64) -> u256 { + let mut ticket = self._load_ticket(ticket_id); + let caller = get_caller_address(); + + assert(ticket.mode == TicketMode::Public, Errors::NOT_PUBLIC_TICKET); + assert(ticket.owner == caller, Errors::NOT_TICKET_OWNER); + + self._settle_refund(ticket_id, ref ticket, caller) + } + + fn refund_anonymous( + ref self: ContractState, + event_id: u64, + secret: felt252, + nullifier: felt252, + recipient: ContractAddress, + ) -> u256 { + assert(recipient.is_non_zero(), Errors::ZERO_ADDRESS); + + let commitment = self._commitment(secret, nullifier); + let ticket_id = self.ticket_by_commitment.entry(event_id).entry(commitment).read(); + assert(ticket_id != 0, Errors::TICKET_NOT_FOUND); + + let nullifier_hash = self._nullifier_hash(nullifier); + assert( + !self.nullifier_used.entry(event_id).entry(nullifier_hash).read(), + Errors::NULLIFIER_USED, + ); + self.nullifier_used.entry(event_id).entry(nullifier_hash).write(true); + + let mut ticket = self.tickets.entry(ticket_id).read(); + self._settle_refund(ticket_id, ref ticket, recipient) + } + + // ── Views ── + fn get_event(self: @ContractState, event_id: u64) -> EventData { + self.events.entry(event_id).read() + } + + fn get_ticket(self: @ContractState, ticket_id: u64) -> TicketData { + self.tickets.entry(ticket_id).read() + } + + fn ticket_of(self: @ContractState, event_id: u64, attendee: ContractAddress) -> u64 { + self.ticket_by_attendee.entry(event_id).entry(attendee).read() + } + + fn ticket_of_commitment(self: @ContractState, event_id: u64, commitment: felt252) -> u64 { + self.ticket_by_commitment.entry(event_id).entry(commitment).read() + } + + fn is_nullifier_used(self: @ContractState, event_id: u64, nullifier_hash: felt252) -> bool { + self.nullifier_used.entry(event_id).entry(nullifier_hash).read() + } + + fn tickets_remaining(self: @ContractState, event_id: u64) -> u32 { + let event = self.events.entry(event_id).read(); + if event.tickets_sold >= event.max_attendees { + 0 + } else { + event.max_attendees - event.tickets_sold + } + } + + fn events_count(self: @ContractState) -> u64 { + self.events_count.read() + } + + fn tickets_count(self: @ContractState) -> u64 { + self.tickets_count.read() + } + + fn compute_commitment( + self: @ContractState, secret: felt252, nullifier: felt252, + ) -> felt252 { + self._commitment(secret, nullifier) + } + + fn compute_nullifier_hash(self: @ContractState, nullifier: felt252) -> felt252 { + self._nullifier_hash(nullifier) + } + + // ── Admin ── + fn payment_token(self: @ContractState) -> ContractAddress { + self.payment_token.read() + } + + fn platform_fee_bps(self: @ContractState) -> u16 { + self.platform_fee_bps.read() + } + + fn fee_recipient(self: @ContractState) -> ContractAddress { + self.fee_recipient.read() + } + + fn owner(self: @ContractState) -> ContractAddress { + self.owner.read() + } + + fn set_platform_fee_bps(ref self: ContractState, bps: u16) { + self._assert_only_owner(); + assert(bps <= MAX_FEE_BPS, Errors::FEE_TOO_HIGH); + let old_bps = self.platform_fee_bps.read(); + self.platform_fee_bps.write(bps); + self.emit(Event::PlatformFeeUpdated(PlatformFeeUpdated { old_bps, new_bps: bps })); + } + + fn set_fee_recipient(ref self: ContractState, recipient: ContractAddress) { + self._assert_only_owner(); + assert(recipient.is_non_zero(), Errors::ZERO_ADDRESS); + let old_recipient = self.fee_recipient.read(); + self.fee_recipient.write(recipient); + self + .emit( + Event::FeeRecipientUpdated( + FeeRecipientUpdated { old_recipient, new_recipient: recipient }, + ), + ); + } + + fn transfer_ownership(ref self: ContractState, new_owner: ContractAddress) { + self._assert_only_owner(); + assert(new_owner.is_non_zero(), Errors::ZERO_ADDRESS); + let previous_owner = self.owner.read(); + self.owner.write(new_owner); + self + .emit( + Event::OwnershipTransferred(OwnershipTransferred { previous_owner, new_owner }), + ); + } + } + + #[generate_trait] + impl InternalImpl of InternalTrait { + fn _assert_only_owner(self: @ContractState) { + assert(self.owner.read() == get_caller_address(), Errors::NOT_OWNER); + } + + fn _load_event(self: @ContractState, event_id: u64) -> EventData { + let event = self.events.entry(event_id).read(); + assert(event.organizer.is_non_zero(), Errors::EVENT_NOT_FOUND); + event + } + + fn _load_ticket(self: @ContractState, ticket_id: u64) -> TicketData { + let ticket = self.tickets.entry(ticket_id).read(); + assert(ticket.event_id != 0, Errors::TICKET_NOT_FOUND); + ticket + } + + fn _assert_sale_open(self: @ContractState, event: @EventData) { + assert(!*event.cancelled, Errors::EVENT_CANCELLED); + assert(*event.tickets_sold < *event.max_attendees, Errors::SOLD_OUT); + assert(get_block_timestamp() < *event.end_time, Errors::SALE_CLOSED); + } + + /// Pulls `amount` of the payment token from `payer` into the contract. + /// Free events (`amount == 0`) skip the transfer entirely so guests never + /// need a funded wallet. + fn _collect_payment(ref self: ContractState, payer: ContractAddress, amount: u256) { + if amount == 0 { + return; + } + let token = IERC20Dispatcher { contract_address: self.payment_token.read() }; + assert( + token.transfer_from(payer, get_contract_address(), amount), Errors::PAYMENT_FAILED, + ); + } + + fn _settle_refund( + ref self: ContractState, + ticket_id: u64, + ref ticket: TicketData, + recipient: ContractAddress, + ) -> u256 { + let mut event = self._load_event(ticket.event_id); + assert(event.cancelled, Errors::EVENT_NOT_CANCELLED); + assert(!ticket.refunded, Errors::ALREADY_REFUNDED); + + let amount = ticket.paid; + ticket.refunded = true; + self.tickets.entry(ticket_id).write(ticket); + + event.escrow -= amount; + self.events.entry(ticket.event_id).write(event); + + if amount > 0 { + let token = IERC20Dispatcher { contract_address: self.payment_token.read() }; + assert(token.transfer(recipient, amount), Errors::PAYMENT_FAILED); + } + + self + .emit( + Event::Refunded( + Refunded { event_id: ticket.event_id, ticket_id, recipient, amount }, + ), + ); + + amount + } + + fn _commitment(self: @ContractState, secret: felt252, nullifier: felt252) -> felt252 { + PoseidonTrait::new().update(secret).update(nullifier).finalize() + } + + fn _nullifier_hash(self: @ContractState, nullifier: felt252) -> felt252 { + PoseidonTrait::new().update(nullifier).finalize() + } + } +} diff --git a/contracts/tests/test_zicket.cairo b/contracts/tests/test_zicket.cairo new file mode 100644 index 0000000..8a899ed --- /dev/null +++ b/contracts/tests/test_zicket.cairo @@ -0,0 +1,626 @@ +use snforge_std::{ + ContractClassTrait, DeclareResultTrait, declare, start_cheat_block_timestamp_global, + start_cheat_caller_address, stop_cheat_caller_address, +}; +use starknet::ContractAddress; +use zicket::interfaces::{ + IERC20Dispatcher, IERC20DispatcherTrait, IZicketEventsDispatcher, IZicketEventsDispatcherTrait, +}; +use zicket::mock_erc20::MockERC20::{IMockERC20MetaDispatcher, IMockERC20MetaDispatcherTrait}; +use zicket::types::TicketMode; + +// ───────────────────────────────────────────────────────────────────────────── +// Fixtures +// ───────────────────────────────────────────────────────────────────────────── + +const PRICE: u256 = 1000; +const FEE_BPS: u16 = 250; // 2.5% +const START_TIME: u64 = 2_000; +const END_TIME: u64 = 10_000; + +fn owner() -> ContractAddress { + 'OWNER'.try_into().unwrap() +} +fn organizer() -> ContractAddress { + 'ORGANIZER'.try_into().unwrap() +} +fn alice() -> ContractAddress { + 'ALICE'.try_into().unwrap() +} +fn bob() -> ContractAddress { + 'BOB'.try_into().unwrap() +} +fn fee_recipient() -> ContractAddress { + 'FEE'.try_into().unwrap() +} + +#[derive(Copy, Drop)] +struct Ctx { + zicket: IZicketEventsDispatcher, + token: IERC20Dispatcher, + token_admin: IMockERC20MetaDispatcher, + zicket_address: ContractAddress, +} + +fn setup() -> Ctx { + let erc20_class = declare("MockERC20").unwrap().contract_class(); + let mut erc20_calldata = array![]; + let name: ByteArray = "Mock Starknet Token"; + let symbol: ByteArray = "mSTRK"; + name.serialize(ref erc20_calldata); + symbol.serialize(ref erc20_calldata); + 18_u8.serialize(ref erc20_calldata); + 0_u256.serialize(ref erc20_calldata); + owner().serialize(ref erc20_calldata); + let (token_address, _) = erc20_class.deploy(@erc20_calldata).unwrap(); + + let zicket_class = declare("ZicketEvents").unwrap().contract_class(); + let mut calldata = array![]; + owner().serialize(ref calldata); + token_address.serialize(ref calldata); + fee_recipient().serialize(ref calldata); + FEE_BPS.serialize(ref calldata); + let (zicket_address, _) = zicket_class.deploy(@calldata).unwrap(); + + let ctx = Ctx { + zicket: IZicketEventsDispatcher { contract_address: zicket_address }, + token: IERC20Dispatcher { contract_address: token_address }, + token_admin: IMockERC20MetaDispatcher { contract_address: token_address }, + zicket_address, + }; + + // Fund the buyers and pre-approve the protocol. + ctx.token_admin.mint(alice(), 1_000_000); + ctx.token_admin.mint(bob(), 1_000_000); + fund_approval(ctx, alice()); + fund_approval(ctx, bob()); + + start_cheat_block_timestamp_global(START_TIME); + ctx +} + +fn fund_approval(ctx: Ctx, who: ContractAddress) { + start_cheat_caller_address(ctx.token.contract_address, who); + ctx.token.approve(ctx.zicket_address, 1_000_000); + stop_cheat_caller_address(ctx.token.contract_address); +} + +fn create_default_event(ctx: Ctx, anonymous_allowed: bool) -> u64 { + start_cheat_caller_address(ctx.zicket_address, organizer()); + let id = ctx.zicket.create_event('META', PRICE, 100, START_TIME, END_TIME, anonymous_allowed); + stop_cheat_caller_address(ctx.zicket_address); + id +} + +fn buy_as(ctx: Ctx, who: ContractAddress, event_id: u64) -> u64 { + start_cheat_caller_address(ctx.zicket_address, who); + let ticket_id = ctx.zicket.buy_ticket(event_id); + stop_cheat_caller_address(ctx.zicket_address); + ticket_id +} + +// ───────────────────────────────────────────────────────────────────────────── +// Event creation +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn test_create_event_stores_data() { + let ctx = setup(); + let event_id = create_default_event(ctx, true); + + assert(event_id == 1, 'first event id is 1'); + assert(ctx.zicket.events_count() == 1, 'events_count == 1'); + + let event = ctx.zicket.get_event(event_id); + assert(event.organizer == organizer(), 'organizer stored'); + assert(event.metadata_hash == 'META', 'metadata stored'); + assert(event.price == PRICE, 'price stored'); + assert(event.max_attendees == 100, 'capacity stored'); + assert(event.tickets_sold == 0, 'no sales yet'); + assert(event.anonymous_allowed, 'anon allowed'); + assert(!event.cancelled, 'not cancelled'); + assert(ctx.zicket.tickets_remaining(event_id) == 100, 'all remaining'); +} + +#[test] +fn test_event_ids_increment() { + let ctx = setup(); + assert(create_default_event(ctx, false) == 1, 'id 1'); + assert(create_default_event(ctx, false) == 2, 'id 2'); + assert(ctx.zicket.events_count() == 2, 'count 2'); +} + +#[test] +#[should_panic(expected: 'Zicket: invalid capacity')] +fn test_create_event_rejects_zero_capacity() { + let ctx = setup(); + start_cheat_caller_address(ctx.zicket_address, organizer()); + ctx.zicket.create_event('META', PRICE, 0, START_TIME, END_TIME, true); +} + +#[test] +#[should_panic(expected: 'Zicket: invalid time window')] +fn test_create_event_rejects_bad_window() { + let ctx = setup(); + start_cheat_caller_address(ctx.zicket_address, organizer()); + ctx.zicket.create_event('META', PRICE, 10, END_TIME, START_TIME, true); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public tickets +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn test_buy_public_ticket_moves_funds_and_records_owner() { + let ctx = setup(); + let event_id = create_default_event(ctx, false); + + let balance_before = ctx.token.balance_of(alice()); + let ticket_id = buy_as(ctx, alice(), event_id); + + assert(ticket_id == 1, 'first ticket id'); + assert(ctx.token.balance_of(alice()) == balance_before - PRICE, 'buyer debited'); + assert(ctx.token.balance_of(ctx.zicket_address) == PRICE, 'escrowed in contract'); + + let ticket = ctx.zicket.get_ticket(ticket_id); + assert(ticket.event_id == event_id, 'linked to event'); + assert(ticket.owner == alice(), 'owner recorded'); + assert(ticket.mode == TicketMode::Public, 'public mode'); + assert(ticket.commitment == 0, 'no commitment'); + assert(ticket.paid == PRICE, 'paid recorded'); + assert(!ticket.checked_in, 'not checked in'); + + assert(ctx.zicket.ticket_of(event_id, alice()) == ticket_id, 'index updated'); + assert(ctx.zicket.get_event(event_id).tickets_sold == 1, 'sold counter'); + assert(ctx.zicket.tickets_remaining(event_id) == 99, 'remaining decremented'); +} + +#[test] +#[should_panic(expected: 'Zicket: already has ticket')] +fn test_cannot_buy_twice() { + let ctx = setup(); + let event_id = create_default_event(ctx, false); + buy_as(ctx, alice(), event_id); + buy_as(ctx, alice(), event_id); +} + +#[test] +#[should_panic(expected: 'Zicket: sold out')] +fn test_sold_out() { + let ctx = setup(); + start_cheat_caller_address(ctx.zicket_address, organizer()); + let event_id = ctx.zicket.create_event('META', PRICE, 1, START_TIME, END_TIME, false); + stop_cheat_caller_address(ctx.zicket_address); + + buy_as(ctx, alice(), event_id); + assert(ctx.zicket.tickets_remaining(event_id) == 0, 'none remaining'); + buy_as(ctx, bob(), event_id); +} + +#[test] +#[should_panic(expected: 'Zicket: sale closed')] +fn test_sale_closes_after_event_ends() { + let ctx = setup(); + let event_id = create_default_event(ctx, false); + start_cheat_block_timestamp_global(END_TIME + 1); + buy_as(ctx, alice(), event_id); +} + +#[test] +fn test_free_event_requires_no_payment() { + let ctx = setup(); + start_cheat_caller_address(ctx.zicket_address, organizer()); + let event_id = ctx.zicket.create_event('FREE', 0, 50, START_TIME, END_TIME, false); + stop_cheat_caller_address(ctx.zicket_address); + + // `carol` has no tokens and no approval at all. + let carol: ContractAddress = 'CAROL'.try_into().unwrap(); + start_cheat_caller_address(ctx.zicket_address, carol); + let ticket_id = ctx.zicket.buy_ticket(event_id); + stop_cheat_caller_address(ctx.zicket_address); + + assert(ticket_id == 1, 'ticket minted'); + assert(ctx.zicket.get_ticket(ticket_id).paid == 0, 'paid nothing'); + assert(ctx.token.balance_of(ctx.zicket_address) == 0, 'no escrow'); +} + +#[test] +fn test_transfer_ticket_reassigns_index() { + let ctx = setup(); + let event_id = create_default_event(ctx, false); + let ticket_id = buy_as(ctx, alice(), event_id); + + start_cheat_caller_address(ctx.zicket_address, alice()); + ctx.zicket.transfer_ticket(ticket_id, bob()); + stop_cheat_caller_address(ctx.zicket_address); + + assert(ctx.zicket.get_ticket(ticket_id).owner == bob(), 'bob owns it'); + assert(ctx.zicket.ticket_of(event_id, alice()) == 0, 'alice index cleared'); + assert(ctx.zicket.ticket_of(event_id, bob()) == ticket_id, 'bob index set'); +} + +#[test] +#[should_panic(expected: 'Zicket: not ticket owner')] +fn test_transfer_requires_ownership() { + let ctx = setup(); + let event_id = create_default_event(ctx, false); + let ticket_id = buy_as(ctx, alice(), event_id); + + start_cheat_caller_address(ctx.zicket_address, bob()); + ctx.zicket.transfer_ticket(ticket_id, bob()); +} + +#[test] +fn test_check_in_by_holder_and_by_organizer() { + let ctx = setup(); + let event_id = create_default_event(ctx, false); + let alice_ticket = buy_as(ctx, alice(), event_id); + let bob_ticket = buy_as(ctx, bob(), event_id); + + start_cheat_caller_address(ctx.zicket_address, alice()); + ctx.zicket.check_in(alice_ticket); + stop_cheat_caller_address(ctx.zicket_address); + + start_cheat_caller_address(ctx.zicket_address, organizer()); + ctx.zicket.check_in(bob_ticket); + stop_cheat_caller_address(ctx.zicket_address); + + assert(ctx.zicket.get_ticket(alice_ticket).checked_in, 'alice in'); + assert(ctx.zicket.get_ticket(bob_ticket).checked_in, 'bob in'); +} + +#[test] +#[should_panic(expected: 'Zicket: already checked in')] +fn test_public_double_check_in_reverts() { + let ctx = setup(); + let event_id = create_default_event(ctx, false); + let ticket_id = buy_as(ctx, alice(), event_id); + + start_cheat_caller_address(ctx.zicket_address, alice()); + ctx.zicket.check_in(ticket_id); + ctx.zicket.check_in(ticket_id); +} + +#[test] +#[should_panic(expected: 'Zicket: not ticket owner')] +fn test_stranger_cannot_check_in() { + let ctx = setup(); + let event_id = create_default_event(ctx, false); + let ticket_id = buy_as(ctx, alice(), event_id); + + start_cheat_caller_address(ctx.zicket_address, bob()); + ctx.zicket.check_in(ticket_id); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Anonymous tickets +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn test_anonymous_purchase_does_not_record_buyer() { + let ctx = setup(); + let event_id = create_default_event(ctx, true); + let commitment = ctx.zicket.compute_commitment('secret', 'nullifier'); + + start_cheat_caller_address(ctx.zicket_address, alice()); + let ticket_id = ctx.zicket.buy_ticket_anonymous(event_id, commitment); + stop_cheat_caller_address(ctx.zicket_address); + + let ticket = ctx.zicket.get_ticket(ticket_id); + assert(ticket.mode == TicketMode::Anonymous, 'anon mode'); + assert(ticket.commitment == commitment, 'commitment stored'); + assert(ticket.owner.into() == 0_felt252, 'no owner recorded'); + // The paying wallet is not linkable to the ticket through contract storage. + assert(ctx.zicket.ticket_of(event_id, alice()) == 0, 'buyer not indexed'); + assert( + ctx.zicket.ticket_of_commitment(event_id, commitment) == ticket_id, 'commitment indexed', + ); + // Funds were still collected from the payer. + assert(ctx.token.balance_of(ctx.zicket_address) == PRICE, 'escrowed'); +} + +#[test] +fn test_relayer_can_buy_and_a_different_wallet_checks_in() { + let ctx = setup(); + let event_id = create_default_event(ctx, true); + let commitment = ctx.zicket.compute_commitment('s1', 'n1'); + + // Bob acts as the relayer and pays. + start_cheat_caller_address(ctx.zicket_address, bob()); + let ticket_id = ctx.zicket.buy_ticket_anonymous(event_id, commitment); + stop_cheat_caller_address(ctx.zicket_address); + + // A wallet that never touched the purchase redeems it with the secret. + let stranger: ContractAddress = 'STRANGER'.try_into().unwrap(); + start_cheat_caller_address(ctx.zicket_address, stranger); + let redeemed = ctx.zicket.check_in_anonymous(event_id, 's1', 'n1'); + stop_cheat_caller_address(ctx.zicket_address); + + assert(redeemed == ticket_id, 'same ticket'); + assert(ctx.zicket.get_ticket(ticket_id).checked_in, 'checked in'); + let nullifier_hash = ctx.zicket.compute_nullifier_hash('n1'); + assert(ctx.zicket.is_nullifier_used(event_id, nullifier_hash), 'nullifier burnt'); +} + +#[test] +#[should_panic(expected: 'Zicket: nullifier used')] +fn test_anonymous_double_check_in_reverts() { + let ctx = setup(); + let event_id = create_default_event(ctx, true); + let commitment = ctx.zicket.compute_commitment('s2', 'n2'); + + start_cheat_caller_address(ctx.zicket_address, alice()); + ctx.zicket.buy_ticket_anonymous(event_id, commitment); + ctx.zicket.check_in_anonymous(event_id, 's2', 'n2'); + ctx.zicket.check_in_anonymous(event_id, 's2', 'n2'); +} + +#[test] +#[should_panic(expected: 'Zicket: ticket not found')] +fn test_check_in_with_wrong_secret_reverts() { + let ctx = setup(); + let event_id = create_default_event(ctx, true); + let commitment = ctx.zicket.compute_commitment('s3', 'n3'); + + start_cheat_caller_address(ctx.zicket_address, alice()); + ctx.zicket.buy_ticket_anonymous(event_id, commitment); + ctx.zicket.check_in_anonymous(event_id, 'wrong', 'n3'); +} + +#[test] +#[should_panic(expected: 'Zicket: commitment used')] +fn test_duplicate_commitment_reverts() { + let ctx = setup(); + let event_id = create_default_event(ctx, true); + let commitment = ctx.zicket.compute_commitment('s4', 'n4'); + + start_cheat_caller_address(ctx.zicket_address, alice()); + ctx.zicket.buy_ticket_anonymous(event_id, commitment); + stop_cheat_caller_address(ctx.zicket_address); + + start_cheat_caller_address(ctx.zicket_address, bob()); + ctx.zicket.buy_ticket_anonymous(event_id, commitment); +} + +#[test] +#[should_panic(expected: 'Zicket: anon not allowed')] +fn test_anonymous_blocked_when_disabled() { + let ctx = setup(); + let event_id = create_default_event(ctx, false); + let commitment = ctx.zicket.compute_commitment('s5', 'n5'); + + start_cheat_caller_address(ctx.zicket_address, alice()); + ctx.zicket.buy_ticket_anonymous(event_id, commitment); +} + +#[test] +fn test_commitment_is_deterministic_and_distinct() { + let ctx = setup(); + let a = ctx.zicket.compute_commitment('s', 'n'); + let b = ctx.zicket.compute_commitment('s', 'n'); + let c = ctx.zicket.compute_commitment('n', 's'); + assert(a == b, 'deterministic'); + assert(a != c, 'order matters'); + assert(a != ctx.zicket.compute_nullifier_hash('n'), 'distinct domains'); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Settlement +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn test_withdraw_splits_platform_fee() { + let ctx = setup(); + let event_id = create_default_event(ctx, false); + buy_as(ctx, alice(), event_id); + buy_as(ctx, bob(), event_id); + + start_cheat_block_timestamp_global(END_TIME + 1); + start_cheat_caller_address(ctx.zicket_address, organizer()); + let payout = ctx.zicket.withdraw(event_id); + stop_cheat_caller_address(ctx.zicket_address); + + let gross = PRICE * 2; + let expected_fee = gross * FEE_BPS.into() / 10000; + assert(payout == gross - expected_fee, 'payout net of fee'); + assert(ctx.token.balance_of(organizer()) == gross - expected_fee, 'organizer paid'); + assert(ctx.token.balance_of(fee_recipient()) == expected_fee, 'fee paid'); + assert(ctx.token.balance_of(ctx.zicket_address) == 0, 'escrow drained'); + assert(ctx.zicket.get_event(event_id).withdrawn, 'marked withdrawn'); +} + +#[test] +#[should_panic(expected: 'Zicket: event not ended')] +fn test_withdraw_before_end_reverts() { + let ctx = setup(); + let event_id = create_default_event(ctx, false); + buy_as(ctx, alice(), event_id); + + start_cheat_caller_address(ctx.zicket_address, organizer()); + ctx.zicket.withdraw(event_id); +} + +#[test] +#[should_panic(expected: 'Zicket: already withdrawn')] +fn test_double_withdraw_reverts() { + let ctx = setup(); + let event_id = create_default_event(ctx, false); + buy_as(ctx, alice(), event_id); + + start_cheat_block_timestamp_global(END_TIME + 1); + start_cheat_caller_address(ctx.zicket_address, organizer()); + ctx.zicket.withdraw(event_id); + ctx.zicket.withdraw(event_id); +} + +#[test] +#[should_panic(expected: 'Zicket: not organizer')] +fn test_only_organizer_withdraws() { + let ctx = setup(); + let event_id = create_default_event(ctx, false); + buy_as(ctx, alice(), event_id); + + start_cheat_block_timestamp_global(END_TIME + 1); + start_cheat_caller_address(ctx.zicket_address, alice()); + ctx.zicket.withdraw(event_id); +} + +#[test] +fn test_cancel_then_refund_public_ticket() { + let ctx = setup(); + let event_id = create_default_event(ctx, false); + let ticket_id = buy_as(ctx, alice(), event_id); + let balance_after_purchase = ctx.token.balance_of(alice()); + + start_cheat_caller_address(ctx.zicket_address, organizer()); + ctx.zicket.cancel_event(event_id); + stop_cheat_caller_address(ctx.zicket_address); + + start_cheat_caller_address(ctx.zicket_address, alice()); + let refunded = ctx.zicket.refund(ticket_id); + stop_cheat_caller_address(ctx.zicket_address); + + assert(refunded == PRICE, 'full refund'); + assert(ctx.token.balance_of(alice()) == balance_after_purchase + PRICE, 'buyer repaid'); + assert(ctx.zicket.get_ticket(ticket_id).refunded, 'marked refunded'); + assert(ctx.zicket.get_event(event_id).escrow == 0, 'escrow cleared'); +} + +#[test] +#[should_panic(expected: 'Zicket: already refunded')] +fn test_double_refund_reverts() { + let ctx = setup(); + let event_id = create_default_event(ctx, false); + let ticket_id = buy_as(ctx, alice(), event_id); + + start_cheat_caller_address(ctx.zicket_address, organizer()); + ctx.zicket.cancel_event(event_id); + stop_cheat_caller_address(ctx.zicket_address); + + start_cheat_caller_address(ctx.zicket_address, alice()); + ctx.zicket.refund(ticket_id); + ctx.zicket.refund(ticket_id); +} + +#[test] +#[should_panic(expected: 'Zicket: event not cancelled')] +fn test_refund_requires_cancellation() { + let ctx = setup(); + let event_id = create_default_event(ctx, false); + let ticket_id = buy_as(ctx, alice(), event_id); + + start_cheat_caller_address(ctx.zicket_address, alice()); + ctx.zicket.refund(ticket_id); +} + +#[test] +fn test_refund_anonymous_pays_chosen_recipient() { + let ctx = setup(); + let event_id = create_default_event(ctx, true); + let commitment = ctx.zicket.compute_commitment('s6', 'n6'); + + start_cheat_caller_address(ctx.zicket_address, alice()); + ctx.zicket.buy_ticket_anonymous(event_id, commitment); + stop_cheat_caller_address(ctx.zicket_address); + + start_cheat_caller_address(ctx.zicket_address, organizer()); + ctx.zicket.cancel_event(event_id); + stop_cheat_caller_address(ctx.zicket_address); + + // Refund routed to a fresh address, unlinked from the payer. + let fresh: ContractAddress = 'FRESH'.try_into().unwrap(); + start_cheat_caller_address(ctx.zicket_address, fresh); + let refunded = ctx.zicket.refund_anonymous(event_id, 's6', 'n6', fresh); + stop_cheat_caller_address(ctx.zicket_address); + + assert(refunded == PRICE, 'full refund'); + assert(ctx.token.balance_of(fresh) == PRICE, 'fresh wallet paid'); +} + +#[test] +#[should_panic(expected: 'Zicket: nullifier used')] +fn test_anonymous_refund_cannot_be_replayed() { + let ctx = setup(); + let event_id = create_default_event(ctx, true); + let commitment = ctx.zicket.compute_commitment('s7', 'n7'); + + start_cheat_caller_address(ctx.zicket_address, alice()); + ctx.zicket.buy_ticket_anonymous(event_id, commitment); + stop_cheat_caller_address(ctx.zicket_address); + + start_cheat_caller_address(ctx.zicket_address, organizer()); + ctx.zicket.cancel_event(event_id); + stop_cheat_caller_address(ctx.zicket_address); + + start_cheat_caller_address(ctx.zicket_address, alice()); + ctx.zicket.refund_anonymous(event_id, 's7', 'n7', alice()); + ctx.zicket.refund_anonymous(event_id, 's7', 'n7', alice()); +} + +#[test] +#[should_panic(expected: 'Zicket: event cancelled')] +fn test_cannot_buy_after_cancel() { + let ctx = setup(); + let event_id = create_default_event(ctx, false); + + start_cheat_caller_address(ctx.zicket_address, organizer()); + ctx.zicket.cancel_event(event_id); + stop_cheat_caller_address(ctx.zicket_address); + + buy_as(ctx, alice(), event_id); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Admin +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn test_admin_can_update_fee_and_recipient() { + let ctx = setup(); + start_cheat_caller_address(ctx.zicket_address, owner()); + ctx.zicket.set_platform_fee_bps(500); + ctx.zicket.set_fee_recipient(bob()); + stop_cheat_caller_address(ctx.zicket_address); + + assert(ctx.zicket.platform_fee_bps() == 500, 'fee updated'); + assert(ctx.zicket.fee_recipient() == bob(), 'recipient updated'); +} + +#[test] +#[should_panic(expected: 'Zicket: not owner')] +fn test_non_owner_cannot_update_fee() { + let ctx = setup(); + start_cheat_caller_address(ctx.zicket_address, alice()); + ctx.zicket.set_platform_fee_bps(500); +} + +#[test] +#[should_panic(expected: 'Zicket: fee too high')] +fn test_fee_is_capped() { + let ctx = setup(); + start_cheat_caller_address(ctx.zicket_address, owner()); + ctx.zicket.set_platform_fee_bps(1001); +} + +#[test] +fn test_ownership_transfer() { + let ctx = setup(); + start_cheat_caller_address(ctx.zicket_address, owner()); + ctx.zicket.transfer_ownership(alice()); + stop_cheat_caller_address(ctx.zicket_address); + + assert(ctx.zicket.owner() == alice(), 'new owner'); + + start_cheat_caller_address(ctx.zicket_address, alice()); + ctx.zicket.set_platform_fee_bps(100); + stop_cheat_caller_address(ctx.zicket_address); + assert(ctx.zicket.platform_fee_bps() == 100, 'new owner can admin'); +} + +#[test] +fn test_config_exposed() { + let ctx = setup(); + assert(ctx.zicket.payment_token() == ctx.token.contract_address, 'token exposed'); + assert(ctx.zicket.platform_fee_bps() == FEE_BPS, 'fee exposed'); + assert(ctx.zicket.owner() == owner(), 'owner exposed'); +} diff --git a/deployments/devnet.json b/deployments/devnet.json new file mode 100644 index 0000000..530b3ef --- /dev/null +++ b/deployments/devnet.json @@ -0,0 +1,22 @@ +{ + "network": "devnet", + "chainId": "0x534e5f5345504f4c4941", + "rpcUrl": "http://127.0.0.1:5050", + "deployer": "0x64b48806902a367c8598f4f95c305e8c1a1acba5f082d294a43793113115691", + "feeRecipient": "0x64b48806902a367c8598f4f95c305e8c1a1acba5f082d294a43793113115691", + "platformFeeBps": 250, + "deployedAt": "2026-07-29T01:47:49.840Z", + "contracts": { + "ZicketEvents": { + "address": "0x5cd4415712902454e685b57c5717c9855e6af06421be621753cf09679e7f91c", + "classHash": "0x7389d6d0d3eb5d56d385941bb3562b74f386ef1cb04155662b6bb2a192e809d" + }, + "PaymentToken": { + "address": "0x74193d47322272e5dc06d3d67da225a4e46ae2ec55b3d4ed8e1931f2a731dcb", + "classHash": "0x257e94e709dacfa2091a4ffa8fb8744d2a3c572429abf04d94202dc15681720", + "symbol": "ZUSD", + "decimals": 18, + "mock": true + } + } +} diff --git a/deployments/sepolia.json b/deployments/sepolia.json new file mode 100644 index 0000000..2ad5f1a --- /dev/null +++ b/deployments/sepolia.json @@ -0,0 +1,22 @@ +{ + "network": "sepolia", + "chainId": "0x534e5f5345504f4c4941", + "rpcUrl": "https://api.cartridge.gg/x/starknet/sepolia", + "deployer": "0x6b4ff7d3d4ec97092b655ca7569598583e6e7c236ea37c23cc2664725687994", + "feeRecipient": "0x6b4ff7d3d4ec97092b655ca7569598583e6e7c236ea37c23cc2664725687994", + "platformFeeBps": 250, + "deployedAt": "2026-07-29T07:41:49.773Z", + "contracts": { + "ZicketEvents": { + "address": "0x7b318fc47e158025e02cb41c6b6ae74d214e1d5b8c58241b09f8b0b452b8cbb", + "classHash": "0x7389d6d0d3eb5d56d385941bb3562b74f386ef1cb04155662b6bb2a192e809d" + }, + "PaymentToken": { + "address": "0x785ab439b53aa6452deaa13a8bbaca43004284e6c3bebadf07f8ebb73333f15", + "classHash": "0x257e94e709dacfa2091a4ffa8fb8744d2a3c572429abf04d94202dc15681720", + "symbol": "ZUSD", + "decimals": 18, + "mock": true + } + } +} diff --git a/drizzle.config.ts b/drizzle.config.ts index b12b610..9b50205 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,6 +1,10 @@ -import "dotenv/config"; +import { config } from "dotenv"; import { defineConfig } from "drizzle-kit"; +// .env.local mirrors Next.js' own precedence, so drizzle-kit and the app agree. +config({ path: ".env.local", quiet: true }); +config({ quiet: true }); + export default defineConfig({ out: "./drizzle", schema: "./src/db/schema.ts", diff --git a/drizzle/0000_wandering_raider.sql b/drizzle/0000_wandering_raider.sql new file mode 100644 index 0000000..1ada9fd --- /dev/null +++ b/drizzle/0000_wandering_raider.sql @@ -0,0 +1,58 @@ +CREATE TABLE "news_items" ( + "id" varchar(64) PRIMARY KEY NOT NULL, + "image" text NOT NULL, + "category" varchar(64) NOT NULL, + "date" varchar(64) NOT NULL, + "title" varchar(255) NOT NULL, + "description" text NOT NULL, + "content" text DEFAULT '' NOT NULL, + "author_name" varchar(128) NOT NULL, + "author_avatar" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "newsletter_subscribers" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "newsletter_subscribers_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "email" varchar(255) NOT NULL, + "source" varchar(64), + "subscribed_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "newsletter_subscribers_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "ticket_purchases" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "ticket_purchases_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "ticket_id" varchar(64) NOT NULL, + "onchain_event_id" integer NOT NULL, + "onchain_ticket_id" integer, + "mode" varchar(16) DEFAULT 'public' NOT NULL, + "commitment" varchar(66), + "buyer_address" varchar(66), + "tx_hash" varchar(66) NOT NULL, + "status" varchar(16) DEFAULT 'pending' NOT NULL, + "email" varchar(255), + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "tickets" ( + "id" varchar(64) PRIMARY KEY NOT NULL, + "event_id" varchar(64) NOT NULL, + "title" varchar(255) NOT NULL, + "image" text NOT NULL, + "no_of_attendees" integer DEFAULT 0 NOT NULL, + "attendees" jsonb DEFAULT '[]'::jsonb NOT NULL, + "event_date" integer NOT NULL, + "event_time_in_utc" varchar(64) NOT NULL, + "event_location" varchar(128) NOT NULL, + "anonymous" boolean DEFAULT false NOT NULL, + "paid" boolean DEFAULT false NOT NULL, + "price_in_usd" numeric(10, 2) NOT NULL, + "event_verified" boolean DEFAULT false NOT NULL, + "onchain_event_id" integer, + "metadata_hash" varchar(66), + "organizer_address" varchar(66), + "publish_tx_hash" varchar(66) +); +--> statement-breakpoint +ALTER TABLE "ticket_purchases" ADD CONSTRAINT "ticket_purchases_ticket_id_tickets_id_fk" FOREIGN KEY ("ticket_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "ticket_purchases_tx_hash_idx" ON "ticket_purchases" USING btree ("tx_hash");--> statement-breakpoint +CREATE INDEX "ticket_purchases_ticket_id_idx" ON "ticket_purchases" USING btree ("ticket_id");--> statement-breakpoint +CREATE INDEX "ticket_purchases_commitment_idx" ON "ticket_purchases" USING btree ("commitment"); \ No newline at end of file diff --git a/drizzle/0001_stormy_galactus.sql b/drizzle/0001_stormy_galactus.sql new file mode 100644 index 0000000..b8da457 --- /dev/null +++ b/drizzle/0001_stormy_galactus.sql @@ -0,0 +1 @@ +ALTER TABLE "tickets" ADD COLUMN "onchain_contract_address" varchar(66); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..a10731d --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,417 @@ +{ + "id": "df5a8535-36e7-44b1-ac19-fcbfc0ff0cb7", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.news_items": { + "name": "news_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "author_name": { + "name": "author_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "author_avatar": { + "name": "author_avatar", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.newsletter_subscribers": { + "name": "newsletter_subscribers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "newsletter_subscribers_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "subscribed_at": { + "name": "subscribed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "newsletter_subscribers_email_unique": { + "name": "newsletter_subscribers_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ticket_purchases": { + "name": "ticket_purchases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "ticket_purchases_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "ticket_id": { + "name": "ticket_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "onchain_event_id": { + "name": "onchain_event_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "onchain_ticket_id": { + "name": "onchain_ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "commitment": { + "name": "commitment", + "type": "varchar(66)", + "primaryKey": false, + "notNull": false + }, + "buyer_address": { + "name": "buyer_address", + "type": "varchar(66)", + "primaryKey": false, + "notNull": false + }, + "tx_hash": { + "name": "tx_hash", + "type": "varchar(66)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ticket_purchases_tx_hash_idx": { + "name": "ticket_purchases_tx_hash_idx", + "columns": [ + { + "expression": "tx_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ticket_purchases_ticket_id_idx": { + "name": "ticket_purchases_ticket_id_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ticket_purchases_commitment_idx": { + "name": "ticket_purchases_commitment_idx", + "columns": [ + { + "expression": "commitment", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ticket_purchases_ticket_id_tickets_id_fk": { + "name": "ticket_purchases_ticket_id_tickets_id_fk", + "tableFrom": "ticket_purchases", + "tableTo": "tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tickets": { + "name": "tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "no_of_attendees": { + "name": "no_of_attendees", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attendees": { + "name": "attendees", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "event_date": { + "name": "event_date", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_time_in_utc": { + "name": "event_time_in_utc", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "event_location": { + "name": "event_location", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "anonymous": { + "name": "anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "paid": { + "name": "paid", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price_in_usd": { + "name": "price_in_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "event_verified": { + "name": "event_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onchain_event_id": { + "name": "onchain_event_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata_hash": { + "name": "metadata_hash", + "type": "varchar(66)", + "primaryKey": false, + "notNull": false + }, + "organizer_address": { + "name": "organizer_address", + "type": "varchar(66)", + "primaryKey": false, + "notNull": false + }, + "publish_tx_hash": { + "name": "publish_tx_hash", + "type": "varchar(66)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..ea82645 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,423 @@ +{ + "id": "5f095510-bb5c-47c8-90c9-107532459912", + "prevId": "df5a8535-36e7-44b1-ac19-fcbfc0ff0cb7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.news_items": { + "name": "news_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "author_name": { + "name": "author_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "author_avatar": { + "name": "author_avatar", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.newsletter_subscribers": { + "name": "newsletter_subscribers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "newsletter_subscribers_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "subscribed_at": { + "name": "subscribed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "newsletter_subscribers_email_unique": { + "name": "newsletter_subscribers_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ticket_purchases": { + "name": "ticket_purchases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "ticket_purchases_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "ticket_id": { + "name": "ticket_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "onchain_event_id": { + "name": "onchain_event_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "onchain_ticket_id": { + "name": "onchain_ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "commitment": { + "name": "commitment", + "type": "varchar(66)", + "primaryKey": false, + "notNull": false + }, + "buyer_address": { + "name": "buyer_address", + "type": "varchar(66)", + "primaryKey": false, + "notNull": false + }, + "tx_hash": { + "name": "tx_hash", + "type": "varchar(66)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ticket_purchases_tx_hash_idx": { + "name": "ticket_purchases_tx_hash_idx", + "columns": [ + { + "expression": "tx_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ticket_purchases_ticket_id_idx": { + "name": "ticket_purchases_ticket_id_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ticket_purchases_commitment_idx": { + "name": "ticket_purchases_commitment_idx", + "columns": [ + { + "expression": "commitment", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ticket_purchases_ticket_id_tickets_id_fk": { + "name": "ticket_purchases_ticket_id_tickets_id_fk", + "tableFrom": "ticket_purchases", + "tableTo": "tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tickets": { + "name": "tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "no_of_attendees": { + "name": "no_of_attendees", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attendees": { + "name": "attendees", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "event_date": { + "name": "event_date", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_time_in_utc": { + "name": "event_time_in_utc", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "event_location": { + "name": "event_location", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "anonymous": { + "name": "anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "paid": { + "name": "paid", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price_in_usd": { + "name": "price_in_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "event_verified": { + "name": "event_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onchain_event_id": { + "name": "onchain_event_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "onchain_contract_address": { + "name": "onchain_contract_address", + "type": "varchar(66)", + "primaryKey": false, + "notNull": false + }, + "metadata_hash": { + "name": "metadata_hash", + "type": "varchar(66)", + "primaryKey": false, + "notNull": false + }, + "organizer_address": { + "name": "organizer_address", + "type": "varchar(66)", + "primaryKey": false, + "notNull": false + }, + "publish_tx_hash": { + "name": "publish_tx_hash", + "type": "varchar(66)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..6f9e323 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1785290542680, + "tag": "0000_wandering_raider", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1785311895203, + "tag": "0001_stormy_galactus", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 7f40c7e..e700ebc 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -1,9 +1,13 @@ import "server-only"; -import { desc, eq, ne } from "drizzle-orm"; +import { desc, eq, ne, sql } from "drizzle-orm"; import { getDb } from "@/src"; -import { tickets as ticketsTable, newsItems as newsItemsTable } from "@/src/db/schema"; -import type { Ticket, NewsItem } from "@/lib/types"; +import { + tickets as ticketsTable, + newsItems as newsItemsTable, + ticketPurchases as ticketPurchasesTable, +} from "@/src/db/schema"; +import type { NewsItem, PurchaseMode, Ticket, TicketPurchase } from "@/lib/types"; // ───────────────────────────────────────────────────────────────────────────── // Ticket Mappers & Queries @@ -24,6 +28,11 @@ function mapRowToTicket(row: typeof ticketsTable.$inferSelect): Ticket { paid: row.paid, price_in_usd: Number(row.priceInUsd), event_verified: row.eventVerified, + onchain_event_id: row.onchainEventId, + onchain_contract_address: row.onchainContractAddress, + metadata_hash: row.metadataHash, + organizer_address: row.organizerAddress, + publish_tx_hash: row.publishTxHash, }; } @@ -119,3 +128,140 @@ export async function getRelatedNews(excludeId: string, limit = 3): Promise { + const db = getDb(); + const rows = await db + .update(ticketsTable) + .set({ + onchainEventId: params.onchainEventId, + onchainContractAddress: params.onchainContractAddress, + metadataHash: params.metadataHash, + organizerAddress: params.organizerAddress, + publishTxHash: params.publishTxHash, + }) + .where(eq(ticketsTable.id, params.ticketId)) + .returning(); + return rows[0] ? mapRowToTicket(rows[0]) : null; +} + +function mapRowToPurchase( + row: typeof ticketPurchasesTable.$inferSelect, +): TicketPurchase { + return { + id: row.id, + ticket_id: row.ticketId, + onchain_event_id: row.onchainEventId, + onchain_ticket_id: row.onchainTicketId, + mode: row.mode as TicketPurchase["mode"], + commitment: row.commitment, + buyer_address: row.buyerAddress, + tx_hash: row.txHash, + status: row.status as TicketPurchase["status"], + created_at: row.createdAt, + }; +} + +/** + * Records a purchase. Idempotent on `tx_hash` so a client retry — or the user + * refreshing mid-confirmation — cannot create duplicate rows. + */ +/** + * Records a purchase, keyed on the transaction hash. + * + * Returns `created: false` when the hash had already been recorded, so callers + * can avoid re-applying side effects (such as bumping the attendee count) when + * a client retries. + */ +export async function recordPurchase(params: { + ticketId: string; + onchainEventId: number; + onchainTicketId?: number | null; + mode: PurchaseMode; + commitment?: string | null; + buyerAddress?: string | null; + txHash: string; + status?: "pending" | "confirmed" | "failed"; + email?: string | null; +}): Promise<{ purchase: TicketPurchase; created: boolean }> { + const db = getDb(); + const existing = await db + .select() + .from(ticketPurchasesTable) + .where(eq(ticketPurchasesTable.txHash, params.txHash)) + .limit(1); + + const rows = await db + .insert(ticketPurchasesTable) + .values({ + ticketId: params.ticketId, + onchainEventId: params.onchainEventId, + onchainTicketId: params.onchainTicketId ?? null, + mode: params.mode, + commitment: params.commitment ?? null, + buyerAddress: params.buyerAddress ?? null, + txHash: params.txHash, + status: params.status ?? "pending", + email: params.email ?? null, + }) + .onConflictDoUpdate({ + target: ticketPurchasesTable.txHash, + set: { + onchainTicketId: params.onchainTicketId ?? null, + status: params.status ?? "pending", + }, + }) + .returning(); + + return { purchase: mapRowToPurchase(rows[0]), created: existing.length === 0 }; +} + +export async function updatePurchaseStatus(params: { + txHash: string; + status: "pending" | "confirmed" | "failed"; + onchainTicketId?: number | null; +}): Promise { + const db = getDb(); + const rows = await db + .update(ticketPurchasesTable) + .set({ + status: params.status, + ...(params.onchainTicketId != null + ? { onchainTicketId: params.onchainTicketId } + : {}), + }) + .where(eq(ticketPurchasesTable.txHash, params.txHash)) + .returning(); + return rows[0] ? mapRowToPurchase(rows[0]) : null; +} + +export async function getPurchasesForTicket(ticketId: string): Promise { + const db = getDb(); + const rows = await db + .select() + .from(ticketPurchasesTable) + .where(eq(ticketPurchasesTable.ticketId, ticketId)) + .orderBy(desc(ticketPurchasesTable.createdAt)); + return rows.map(mapRowToPurchase); +} + +/** Bumps the cached attendee counter shown in the UI. */ +export async function incrementAttendeeCount(ticketId: string): Promise { + const db = getDb(); + await db + .update(ticketsTable) + .set({ noOfAttendees: sql`${ticketsTable.noOfAttendees} + 1` }) + .where(eq(ticketsTable.id, ticketId)); +} diff --git a/lib/starknet/abis/mock-erc20.json b/lib/starknet/abis/mock-erc20.json new file mode 100644 index 0000000..cec2afd --- /dev/null +++ b/lib/starknet/abis/mock-erc20.json @@ -0,0 +1,317 @@ +[ + { + "type": "impl", + "name": "MockERC20Impl", + "interface_name": "zicket::interfaces::IERC20" + }, + { + "type": "struct", + "name": "core::integer::u256", + "members": [ + { + "name": "low", + "type": "core::integer::u128" + }, + { + "name": "high", + "type": "core::integer::u128" + } + ] + }, + { + "type": "enum", + "name": "core::bool", + "variants": [ + { + "name": "False", + "type": "()" + }, + { + "name": "True", + "type": "()" + } + ] + }, + { + "type": "interface", + "name": "zicket::interfaces::IERC20", + "items": [ + { + "type": "function", + "name": "balance_of", + "inputs": [ + { + "name": "account", + "type": "core::starknet::contract_address::ContractAddress" + } + ], + "outputs": [ + { + "type": "core::integer::u256" + } + ], + "state_mutability": "view" + }, + { + "type": "function", + "name": "allowance", + "inputs": [ + { + "name": "owner", + "type": "core::starknet::contract_address::ContractAddress" + }, + { + "name": "spender", + "type": "core::starknet::contract_address::ContractAddress" + } + ], + "outputs": [ + { + "type": "core::integer::u256" + } + ], + "state_mutability": "view" + }, + { + "type": "function", + "name": "transfer", + "inputs": [ + { + "name": "recipient", + "type": "core::starknet::contract_address::ContractAddress" + }, + { + "name": "amount", + "type": "core::integer::u256" + } + ], + "outputs": [ + { + "type": "core::bool" + } + ], + "state_mutability": "external" + }, + { + "type": "function", + "name": "transfer_from", + "inputs": [ + { + "name": "sender", + "type": "core::starknet::contract_address::ContractAddress" + }, + { + "name": "recipient", + "type": "core::starknet::contract_address::ContractAddress" + }, + { + "name": "amount", + "type": "core::integer::u256" + } + ], + "outputs": [ + { + "type": "core::bool" + } + ], + "state_mutability": "external" + }, + { + "type": "function", + "name": "approve", + "inputs": [ + { + "name": "spender", + "type": "core::starknet::contract_address::ContractAddress" + }, + { + "name": "amount", + "type": "core::integer::u256" + } + ], + "outputs": [ + { + "type": "core::bool" + } + ], + "state_mutability": "external" + } + ] + }, + { + "type": "impl", + "name": "MockERC20MetaImpl", + "interface_name": "zicket::mock_erc20::MockERC20::IMockERC20Meta" + }, + { + "type": "struct", + "name": "core::byte_array::ByteArray", + "members": [ + { + "name": "data", + "type": "core::array::Array::" + }, + { + "name": "pending_word", + "type": "core::felt252" + }, + { + "name": "pending_word_len", + "type": "core::internal::bounded_int::BoundedInt::<0, 30>" + } + ] + }, + { + "type": "interface", + "name": "zicket::mock_erc20::MockERC20::IMockERC20Meta", + "items": [ + { + "type": "function", + "name": "name", + "inputs": [], + "outputs": [ + { + "type": "core::byte_array::ByteArray" + } + ], + "state_mutability": "view" + }, + { + "type": "function", + "name": "symbol", + "inputs": [], + "outputs": [ + { + "type": "core::byte_array::ByteArray" + } + ], + "state_mutability": "view" + }, + { + "type": "function", + "name": "decimals", + "inputs": [], + "outputs": [ + { + "type": "core::integer::u8" + } + ], + "state_mutability": "view" + }, + { + "type": "function", + "name": "total_supply", + "inputs": [], + "outputs": [ + { + "type": "core::integer::u256" + } + ], + "state_mutability": "view" + }, + { + "type": "function", + "name": "mint", + "inputs": [ + { + "name": "recipient", + "type": "core::starknet::contract_address::ContractAddress" + }, + { + "name": "amount", + "type": "core::integer::u256" + } + ], + "outputs": [], + "state_mutability": "external" + } + ] + }, + { + "type": "constructor", + "name": "constructor", + "inputs": [ + { + "name": "name", + "type": "core::byte_array::ByteArray" + }, + { + "name": "symbol", + "type": "core::byte_array::ByteArray" + }, + { + "name": "decimals", + "type": "core::integer::u8" + }, + { + "name": "initial_supply", + "type": "core::integer::u256" + }, + { + "name": "recipient", + "type": "core::starknet::contract_address::ContractAddress" + } + ] + }, + { + "type": "event", + "name": "zicket::mock_erc20::MockERC20::Transfer", + "kind": "struct", + "members": [ + { + "name": "from", + "type": "core::starknet::contract_address::ContractAddress", + "kind": "key" + }, + { + "name": "to", + "type": "core::starknet::contract_address::ContractAddress", + "kind": "key" + }, + { + "name": "value", + "type": "core::integer::u256", + "kind": "data" + } + ] + }, + { + "type": "event", + "name": "zicket::mock_erc20::MockERC20::Approval", + "kind": "struct", + "members": [ + { + "name": "owner", + "type": "core::starknet::contract_address::ContractAddress", + "kind": "key" + }, + { + "name": "spender", + "type": "core::starknet::contract_address::ContractAddress", + "kind": "key" + }, + { + "name": "value", + "type": "core::integer::u256", + "kind": "data" + } + ] + }, + { + "type": "event", + "name": "zicket::mock_erc20::MockERC20::Event", + "kind": "enum", + "variants": [ + { + "name": "Transfer", + "type": "zicket::mock_erc20::MockERC20::Transfer", + "kind": "nested" + }, + { + "name": "Approval", + "type": "zicket::mock_erc20::MockERC20::Approval", + "kind": "nested" + } + ] + } +] diff --git a/lib/starknet/abis/zicket-events.json b/lib/starknet/abis/zicket-events.json new file mode 100644 index 0000000..e900c8c --- /dev/null +++ b/lib/starknet/abis/zicket-events.json @@ -0,0 +1,956 @@ +[ + { + "type": "impl", + "name": "ZicketEventsImpl", + "interface_name": "zicket::interfaces::IZicketEvents" + }, + { + "type": "struct", + "name": "core::integer::u256", + "members": [ + { + "name": "low", + "type": "core::integer::u128" + }, + { + "name": "high", + "type": "core::integer::u128" + } + ] + }, + { + "type": "enum", + "name": "core::bool", + "variants": [ + { + "name": "False", + "type": "()" + }, + { + "name": "True", + "type": "()" + } + ] + }, + { + "type": "struct", + "name": "zicket::types::EventData", + "members": [ + { + "name": "organizer", + "type": "core::starknet::contract_address::ContractAddress" + }, + { + "name": "metadata_hash", + "type": "core::felt252" + }, + { + "name": "price", + "type": "core::integer::u256" + }, + { + "name": "max_attendees", + "type": "core::integer::u32" + }, + { + "name": "tickets_sold", + "type": "core::integer::u32" + }, + { + "name": "start_time", + "type": "core::integer::u64" + }, + { + "name": "end_time", + "type": "core::integer::u64" + }, + { + "name": "anonymous_allowed", + "type": "core::bool" + }, + { + "name": "cancelled", + "type": "core::bool" + }, + { + "name": "escrow", + "type": "core::integer::u256" + }, + { + "name": "withdrawn", + "type": "core::bool" + } + ] + }, + { + "type": "enum", + "name": "zicket::types::TicketMode", + "variants": [ + { + "name": "Public", + "type": "()" + }, + { + "name": "Anonymous", + "type": "()" + } + ] + }, + { + "type": "struct", + "name": "zicket::types::TicketData", + "members": [ + { + "name": "event_id", + "type": "core::integer::u64" + }, + { + "name": "owner", + "type": "core::starknet::contract_address::ContractAddress" + }, + { + "name": "commitment", + "type": "core::felt252" + }, + { + "name": "mode", + "type": "zicket::types::TicketMode" + }, + { + "name": "paid", + "type": "core::integer::u256" + }, + { + "name": "purchased_at", + "type": "core::integer::u64" + }, + { + "name": "checked_in", + "type": "core::bool" + }, + { + "name": "refunded", + "type": "core::bool" + } + ] + }, + { + "type": "interface", + "name": "zicket::interfaces::IZicketEvents", + "items": [ + { + "type": "function", + "name": "create_event", + "inputs": [ + { + "name": "metadata_hash", + "type": "core::felt252" + }, + { + "name": "price", + "type": "core::integer::u256" + }, + { + "name": "max_attendees", + "type": "core::integer::u32" + }, + { + "name": "start_time", + "type": "core::integer::u64" + }, + { + "name": "end_time", + "type": "core::integer::u64" + }, + { + "name": "anonymous_allowed", + "type": "core::bool" + } + ], + "outputs": [ + { + "type": "core::integer::u64" + } + ], + "state_mutability": "external" + }, + { + "type": "function", + "name": "cancel_event", + "inputs": [ + { + "name": "event_id", + "type": "core::integer::u64" + } + ], + "outputs": [], + "state_mutability": "external" + }, + { + "type": "function", + "name": "withdraw", + "inputs": [ + { + "name": "event_id", + "type": "core::integer::u64" + } + ], + "outputs": [ + { + "type": "core::integer::u256" + } + ], + "state_mutability": "external" + }, + { + "type": "function", + "name": "buy_ticket", + "inputs": [ + { + "name": "event_id", + "type": "core::integer::u64" + } + ], + "outputs": [ + { + "type": "core::integer::u64" + } + ], + "state_mutability": "external" + }, + { + "type": "function", + "name": "buy_ticket_anonymous", + "inputs": [ + { + "name": "event_id", + "type": "core::integer::u64" + }, + { + "name": "commitment", + "type": "core::felt252" + } + ], + "outputs": [ + { + "type": "core::integer::u64" + } + ], + "state_mutability": "external" + }, + { + "type": "function", + "name": "transfer_ticket", + "inputs": [ + { + "name": "ticket_id", + "type": "core::integer::u64" + }, + { + "name": "to", + "type": "core::starknet::contract_address::ContractAddress" + } + ], + "outputs": [], + "state_mutability": "external" + }, + { + "type": "function", + "name": "check_in", + "inputs": [ + { + "name": "ticket_id", + "type": "core::integer::u64" + } + ], + "outputs": [], + "state_mutability": "external" + }, + { + "type": "function", + "name": "check_in_anonymous", + "inputs": [ + { + "name": "event_id", + "type": "core::integer::u64" + }, + { + "name": "secret", + "type": "core::felt252" + }, + { + "name": "nullifier", + "type": "core::felt252" + } + ], + "outputs": [ + { + "type": "core::integer::u64" + } + ], + "state_mutability": "external" + }, + { + "type": "function", + "name": "refund", + "inputs": [ + { + "name": "ticket_id", + "type": "core::integer::u64" + } + ], + "outputs": [ + { + "type": "core::integer::u256" + } + ], + "state_mutability": "external" + }, + { + "type": "function", + "name": "refund_anonymous", + "inputs": [ + { + "name": "event_id", + "type": "core::integer::u64" + }, + { + "name": "secret", + "type": "core::felt252" + }, + { + "name": "nullifier", + "type": "core::felt252" + }, + { + "name": "recipient", + "type": "core::starknet::contract_address::ContractAddress" + } + ], + "outputs": [ + { + "type": "core::integer::u256" + } + ], + "state_mutability": "external" + }, + { + "type": "function", + "name": "get_event", + "inputs": [ + { + "name": "event_id", + "type": "core::integer::u64" + } + ], + "outputs": [ + { + "type": "zicket::types::EventData" + } + ], + "state_mutability": "view" + }, + { + "type": "function", + "name": "get_ticket", + "inputs": [ + { + "name": "ticket_id", + "type": "core::integer::u64" + } + ], + "outputs": [ + { + "type": "zicket::types::TicketData" + } + ], + "state_mutability": "view" + }, + { + "type": "function", + "name": "ticket_of", + "inputs": [ + { + "name": "event_id", + "type": "core::integer::u64" + }, + { + "name": "attendee", + "type": "core::starknet::contract_address::ContractAddress" + } + ], + "outputs": [ + { + "type": "core::integer::u64" + } + ], + "state_mutability": "view" + }, + { + "type": "function", + "name": "ticket_of_commitment", + "inputs": [ + { + "name": "event_id", + "type": "core::integer::u64" + }, + { + "name": "commitment", + "type": "core::felt252" + } + ], + "outputs": [ + { + "type": "core::integer::u64" + } + ], + "state_mutability": "view" + }, + { + "type": "function", + "name": "is_nullifier_used", + "inputs": [ + { + "name": "event_id", + "type": "core::integer::u64" + }, + { + "name": "nullifier_hash", + "type": "core::felt252" + } + ], + "outputs": [ + { + "type": "core::bool" + } + ], + "state_mutability": "view" + }, + { + "type": "function", + "name": "tickets_remaining", + "inputs": [ + { + "name": "event_id", + "type": "core::integer::u64" + } + ], + "outputs": [ + { + "type": "core::integer::u32" + } + ], + "state_mutability": "view" + }, + { + "type": "function", + "name": "events_count", + "inputs": [], + "outputs": [ + { + "type": "core::integer::u64" + } + ], + "state_mutability": "view" + }, + { + "type": "function", + "name": "tickets_count", + "inputs": [], + "outputs": [ + { + "type": "core::integer::u64" + } + ], + "state_mutability": "view" + }, + { + "type": "function", + "name": "compute_commitment", + "inputs": [ + { + "name": "secret", + "type": "core::felt252" + }, + { + "name": "nullifier", + "type": "core::felt252" + } + ], + "outputs": [ + { + "type": "core::felt252" + } + ], + "state_mutability": "view" + }, + { + "type": "function", + "name": "compute_nullifier_hash", + "inputs": [ + { + "name": "nullifier", + "type": "core::felt252" + } + ], + "outputs": [ + { + "type": "core::felt252" + } + ], + "state_mutability": "view" + }, + { + "type": "function", + "name": "payment_token", + "inputs": [], + "outputs": [ + { + "type": "core::starknet::contract_address::ContractAddress" + } + ], + "state_mutability": "view" + }, + { + "type": "function", + "name": "platform_fee_bps", + "inputs": [], + "outputs": [ + { + "type": "core::integer::u16" + } + ], + "state_mutability": "view" + }, + { + "type": "function", + "name": "fee_recipient", + "inputs": [], + "outputs": [ + { + "type": "core::starknet::contract_address::ContractAddress" + } + ], + "state_mutability": "view" + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [ + { + "type": "core::starknet::contract_address::ContractAddress" + } + ], + "state_mutability": "view" + }, + { + "type": "function", + "name": "set_platform_fee_bps", + "inputs": [ + { + "name": "bps", + "type": "core::integer::u16" + } + ], + "outputs": [], + "state_mutability": "external" + }, + { + "type": "function", + "name": "set_fee_recipient", + "inputs": [ + { + "name": "recipient", + "type": "core::starknet::contract_address::ContractAddress" + } + ], + "outputs": [], + "state_mutability": "external" + }, + { + "type": "function", + "name": "transfer_ownership", + "inputs": [ + { + "name": "new_owner", + "type": "core::starknet::contract_address::ContractAddress" + } + ], + "outputs": [], + "state_mutability": "external" + } + ] + }, + { + "type": "constructor", + "name": "constructor", + "inputs": [ + { + "name": "owner", + "type": "core::starknet::contract_address::ContractAddress" + }, + { + "name": "payment_token", + "type": "core::starknet::contract_address::ContractAddress" + }, + { + "name": "fee_recipient", + "type": "core::starknet::contract_address::ContractAddress" + }, + { + "name": "platform_fee_bps", + "type": "core::integer::u16" + } + ] + }, + { + "type": "event", + "name": "zicket::zicket_events::ZicketEvents::EventCreated", + "kind": "struct", + "members": [ + { + "name": "event_id", + "type": "core::integer::u64", + "kind": "key" + }, + { + "name": "organizer", + "type": "core::starknet::contract_address::ContractAddress", + "kind": "key" + }, + { + "name": "metadata_hash", + "type": "core::felt252", + "kind": "data" + }, + { + "name": "price", + "type": "core::integer::u256", + "kind": "data" + }, + { + "name": "max_attendees", + "type": "core::integer::u32", + "kind": "data" + }, + { + "name": "start_time", + "type": "core::integer::u64", + "kind": "data" + }, + { + "name": "end_time", + "type": "core::integer::u64", + "kind": "data" + }, + { + "name": "anonymous_allowed", + "type": "core::bool", + "kind": "data" + } + ] + }, + { + "type": "event", + "name": "zicket::zicket_events::ZicketEvents::EventCancelled", + "kind": "struct", + "members": [ + { + "name": "event_id", + "type": "core::integer::u64", + "kind": "key" + }, + { + "name": "cancelled_at", + "type": "core::integer::u64", + "kind": "data" + } + ] + }, + { + "type": "event", + "name": "zicket::zicket_events::ZicketEvents::TicketPurchased", + "kind": "struct", + "members": [ + { + "name": "event_id", + "type": "core::integer::u64", + "kind": "key" + }, + { + "name": "ticket_id", + "type": "core::integer::u64", + "kind": "key" + }, + { + "name": "buyer", + "type": "core::starknet::contract_address::ContractAddress", + "kind": "key" + }, + { + "name": "price", + "type": "core::integer::u256", + "kind": "data" + }, + { + "name": "purchased_at", + "type": "core::integer::u64", + "kind": "data" + } + ] + }, + { + "type": "event", + "name": "zicket::zicket_events::ZicketEvents::AnonymousTicketPurchased", + "kind": "struct", + "members": [ + { + "name": "event_id", + "type": "core::integer::u64", + "kind": "key" + }, + { + "name": "ticket_id", + "type": "core::integer::u64", + "kind": "key" + }, + { + "name": "commitment", + "type": "core::felt252", + "kind": "data" + }, + { + "name": "price", + "type": "core::integer::u256", + "kind": "data" + }, + { + "name": "purchased_at", + "type": "core::integer::u64", + "kind": "data" + } + ] + }, + { + "type": "event", + "name": "zicket::zicket_events::ZicketEvents::TicketTransferred", + "kind": "struct", + "members": [ + { + "name": "ticket_id", + "type": "core::integer::u64", + "kind": "key" + }, + { + "name": "from", + "type": "core::starknet::contract_address::ContractAddress", + "kind": "key" + }, + { + "name": "to", + "type": "core::starknet::contract_address::ContractAddress", + "kind": "key" + } + ] + }, + { + "type": "event", + "name": "zicket::zicket_events::ZicketEvents::CheckedIn", + "kind": "struct", + "members": [ + { + "name": "event_id", + "type": "core::integer::u64", + "kind": "key" + }, + { + "name": "ticket_id", + "type": "core::integer::u64", + "kind": "key" + }, + { + "name": "anonymous", + "type": "core::bool", + "kind": "data" + }, + { + "name": "nullifier_hash", + "type": "core::felt252", + "kind": "data" + }, + { + "name": "checked_in_at", + "type": "core::integer::u64", + "kind": "data" + } + ] + }, + { + "type": "event", + "name": "zicket::zicket_events::ZicketEvents::Refunded", + "kind": "struct", + "members": [ + { + "name": "event_id", + "type": "core::integer::u64", + "kind": "key" + }, + { + "name": "ticket_id", + "type": "core::integer::u64", + "kind": "key" + }, + { + "name": "recipient", + "type": "core::starknet::contract_address::ContractAddress", + "kind": "data" + }, + { + "name": "amount", + "type": "core::integer::u256", + "kind": "data" + } + ] + }, + { + "type": "event", + "name": "zicket::zicket_events::ZicketEvents::Withdrawn", + "kind": "struct", + "members": [ + { + "name": "event_id", + "type": "core::integer::u64", + "kind": "key" + }, + { + "name": "organizer", + "type": "core::starknet::contract_address::ContractAddress", + "kind": "key" + }, + { + "name": "organizer_amount", + "type": "core::integer::u256", + "kind": "data" + }, + { + "name": "fee_amount", + "type": "core::integer::u256", + "kind": "data" + } + ] + }, + { + "type": "event", + "name": "zicket::zicket_events::ZicketEvents::PlatformFeeUpdated", + "kind": "struct", + "members": [ + { + "name": "old_bps", + "type": "core::integer::u16", + "kind": "data" + }, + { + "name": "new_bps", + "type": "core::integer::u16", + "kind": "data" + } + ] + }, + { + "type": "event", + "name": "zicket::zicket_events::ZicketEvents::FeeRecipientUpdated", + "kind": "struct", + "members": [ + { + "name": "old_recipient", + "type": "core::starknet::contract_address::ContractAddress", + "kind": "data" + }, + { + "name": "new_recipient", + "type": "core::starknet::contract_address::ContractAddress", + "kind": "data" + } + ] + }, + { + "type": "event", + "name": "zicket::zicket_events::ZicketEvents::OwnershipTransferred", + "kind": "struct", + "members": [ + { + "name": "previous_owner", + "type": "core::starknet::contract_address::ContractAddress", + "kind": "key" + }, + { + "name": "new_owner", + "type": "core::starknet::contract_address::ContractAddress", + "kind": "key" + } + ] + }, + { + "type": "event", + "name": "zicket::zicket_events::ZicketEvents::Event", + "kind": "enum", + "variants": [ + { + "name": "EventCreated", + "type": "zicket::zicket_events::ZicketEvents::EventCreated", + "kind": "nested" + }, + { + "name": "EventCancelled", + "type": "zicket::zicket_events::ZicketEvents::EventCancelled", + "kind": "nested" + }, + { + "name": "TicketPurchased", + "type": "zicket::zicket_events::ZicketEvents::TicketPurchased", + "kind": "nested" + }, + { + "name": "AnonymousTicketPurchased", + "type": "zicket::zicket_events::ZicketEvents::AnonymousTicketPurchased", + "kind": "nested" + }, + { + "name": "TicketTransferred", + "type": "zicket::zicket_events::ZicketEvents::TicketTransferred", + "kind": "nested" + }, + { + "name": "CheckedIn", + "type": "zicket::zicket_events::ZicketEvents::CheckedIn", + "kind": "nested" + }, + { + "name": "Refunded", + "type": "zicket::zicket_events::ZicketEvents::Refunded", + "kind": "nested" + }, + { + "name": "Withdrawn", + "type": "zicket::zicket_events::ZicketEvents::Withdrawn", + "kind": "nested" + }, + { + "name": "PlatformFeeUpdated", + "type": "zicket::zicket_events::ZicketEvents::PlatformFeeUpdated", + "kind": "nested" + }, + { + "name": "FeeRecipientUpdated", + "type": "zicket::zicket_events::ZicketEvents::FeeRecipientUpdated", + "kind": "nested" + }, + { + "name": "OwnershipTransferred", + "type": "zicket::zicket_events::ZicketEvents::OwnershipTransferred", + "kind": "nested" + } + ] + } +] diff --git a/lib/starknet/commitment.ts b/lib/starknet/commitment.ts new file mode 100644 index 0000000..a3397a6 --- /dev/null +++ b/lib/starknet/commitment.ts @@ -0,0 +1,124 @@ +/** + * Commitment helpers for anonymous tickets. + * + * Mirrors the Cairo implementation in `contracts/src/zicket_events.cairo`: + * + * ```cairo + * commitment = PoseidonTrait::new().update(secret).update(nullifier).finalize() + * nullifier_hash = PoseidonTrait::new().update(nullifier).finalize() + * ``` + * + * `poseidonHashMany` is the JS equivalent of Cairo's `poseidon_hash_span`, which + * is what `PoseidonTrait::finalize()` reduces to. `scripts/starknet/e2e.ts` + * asserts these values against the deployed contract so the two never drift. + * + * Safe to import from both server and client code. + */ +import { poseidonHashMany } from "@scure/starknet"; + +/** Prime modulus of the STARK field. */ +const STARK_PRIME = 2n ** 251n + 17n * 2n ** 192n + 1n; + +export interface TicketSecret { + /** Random felt known only to the attendee. */ + secret: string; + /** Random felt burned at check-in to prevent double entry. */ + nullifier: string; + /** `poseidon(secret, nullifier)` — the only value published on-chain. */ + commitment: string; + /** `poseidon(nullifier)` — revealed at check-in. */ + nullifierHash: string; +} + +function toBigInt(value: string | bigint): bigint { + return typeof value === "bigint" ? value : BigInt(value); +} + +function toHex(value: bigint): string { + return `0x${value.toString(16)}`; +} + +/** Cryptographically random field element, uniform enough for a 248-bit draw. */ +export function randomFelt(): string { + const bytes = new Uint8Array(31); // 248 bits — always below the STARK prime. + crypto.getRandomValues(bytes); + let acc = 0n; + for (const byte of bytes) acc = (acc << 8n) | BigInt(byte); + return toHex(acc % STARK_PRIME); +} + +export function computeCommitment( + secret: string | bigint, + nullifier: string | bigint, +): string { + return toHex(poseidonHashMany([toBigInt(secret), toBigInt(nullifier)])); +} + +export function computeNullifierHash(nullifier: string | bigint): string { + return toHex(poseidonHashMany([toBigInt(nullifier)])); +} + +/** Generates a fresh anonymous-ticket secret bundle. */ +export function generateTicketSecret(): TicketSecret { + const secret = randomFelt(); + const nullifier = randomFelt(); + return { + secret, + nullifier, + commitment: computeCommitment(secret, nullifier), + nullifierHash: computeNullifierHash(nullifier), + }; +} + +/** Re-derives the public values from a stored secret pair. */ +export function deriveTicketSecret(secret: string, nullifier: string): TicketSecret { + return { + secret, + nullifier, + commitment: computeCommitment(secret, nullifier), + nullifierHash: computeNullifierHash(nullifier), + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Local persistence +// +// The secret is the ticket. It is deliberately never sent to the Zicket backend +// for anonymous purchases — losing it means losing the ticket, which is the +// trade-off that makes the ticket unlinkable to an account. +// ───────────────────────────────────────────────────────────────────────────── + +const STORAGE_KEY = "zicket:ticket-secrets"; + +export interface StoredTicketSecret extends TicketSecret { + eventId: string; + onchainEventId: number; + txHash?: string; + createdAt: number; +} + +export function loadStoredSecrets(): StoredTicketSecret[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as StoredTicketSecret[]) : []; + } catch { + return []; + } +} + +export function storeTicketSecret(entry: StoredTicketSecret): void { + if (typeof window === "undefined") return; + const existing = loadStoredSecrets().filter( + (item) => item.commitment !== entry.commitment, + ); + existing.push(entry); + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(existing)); +} + +export function findStoredSecret(commitment: string): StoredTicketSecret | undefined { + const target = BigInt(commitment); + return loadStoredSecrets().find((item) => BigInt(item.commitment) === target); +} diff --git a/lib/starknet/config.ts b/lib/starknet/config.ts new file mode 100644 index 0000000..c108a94 --- /dev/null +++ b/lib/starknet/config.ts @@ -0,0 +1,105 @@ +/** + * Starknet network + deployment configuration. + * + * Values are read from the environment so the same code targets devnet, + * sepolia and mainnet. `pnpm chain:deploy` writes the deployed addresses into + * `.env.local` and `deployments/.json`. + * + * Safe to import from client components — nothing here is secret. + */ + +export type StarknetNetwork = "devnet" | "sepolia" | "mainnet"; + +/** Devnet defaults so a fresh clone works with zero configuration. */ +const DEVNET_RPC_URL = "http://127.0.0.1:5050"; + +export const STARKNET_NETWORK = (process.env.NEXT_PUBLIC_STARKNET_NETWORK ?? + "devnet") as StarknetNetwork; + +export const STARKNET_RPC_URL = + process.env.NEXT_PUBLIC_STARKNET_RPC_URL ?? DEVNET_RPC_URL; + +export const ZICKET_CONTRACT_ADDRESS = + process.env.NEXT_PUBLIC_ZICKET_CONTRACT_ADDRESS ?? ""; + +export const PAYMENT_TOKEN_ADDRESS = + process.env.NEXT_PUBLIC_PAYMENT_TOKEN_ADDRESS ?? ""; + +export const PAYMENT_TOKEN_SYMBOL = + process.env.NEXT_PUBLIC_PAYMENT_TOKEN_SYMBOL ?? "STRK"; + +export const PAYMENT_TOKEN_DECIMALS = Number( + process.env.NEXT_PUBLIC_PAYMENT_TOKEN_DECIMALS ?? 18, +); + +/** + * USD price per unit of the settlement token. The Zicket catalogue prices + * events in USD, so this converts a listing price into token units. A real + * deployment should swap this for an oracle (e.g. Pragma) — it is intentionally + * a single choke point so that swap is a one-line change. + */ +export const TOKEN_USD_PRICE = Number( + process.env.NEXT_PUBLIC_TOKEN_USD_PRICE ?? 1, +); + +/** True once the contracts have been deployed and wired into the environment. */ +export const isChainConfigured = (): boolean => + ZICKET_CONTRACT_ADDRESS.length > 0 && PAYMENT_TOKEN_ADDRESS.length > 0; + +export const EXPLORER_BASE_URL: Record = { + devnet: null, + sepolia: "https://sepolia.voyager.online", + mainnet: "https://voyager.online", +}; + +export function explorerTxUrl(txHash: string): string | null { + const base = EXPLORER_BASE_URL[STARKNET_NETWORK]; + return base ? `${base}/tx/${txHash}` : null; +} + +/** Converts a USD listing price into base units of the settlement token. */ +export function usdToTokenUnits(priceInUsd: number): bigint { + if (!Number.isFinite(priceInUsd) || priceInUsd <= 0) return 0n; + const tokens = priceInUsd / TOKEN_USD_PRICE; + // Round-trip through a fixed-point string to avoid float drift on the exponent. + const scaled = (tokens * 10 ** PAYMENT_TOKEN_DECIMALS).toFixed(0); + return BigInt(scaled); +} + +/** Formats base units back into a human-readable token amount. */ +export function formatTokenUnits(amount: bigint, maxFractionDigits = 4): string { + const divisor = 10n ** BigInt(PAYMENT_TOKEN_DECIMALS); + const whole = amount / divisor; + const fraction = amount % divisor; + if (fraction === 0n) return whole.toString(); + const fractionStr = fraction + .toString() + .padStart(PAYMENT_TOKEN_DECIMALS, "0") + .slice(0, maxFractionDigits) + .replace(/0+$/, ""); + return fractionStr ? `${whole}.${fractionStr}` : whole.toString(); +} + +/** + * True when a listing's stored `onchain_event_id` was issued by the contract + * this app is currently configured against. + * + * Event ids restart at 1 for every ZicketEvents deployment, so a listing + * published against a different contract — a redeploy, or devnet vs sepolia — + * would otherwise be read back against the wrong one and silently resolve to + * an unrelated or non-existent event. + * + * Rows written before the address was recorded are trusted, since only one + * deployment existed at the time. + */ +export function isPublishedToCurrentDeployment< + T extends { + onchain_event_id?: number | null; + onchain_contract_address?: string | null; + }, +>(ticket: T): ticket is T & { onchain_event_id: number } { + if (!ticket.onchain_event_id) return false; + if (!ticket.onchain_contract_address) return true; + if (!ZICKET_CONTRACT_ADDRESS) return false; + return BigInt(ticket.onchain_contract_address) === BigInt(ZICKET_CONTRACT_ADDRESS); +} diff --git a/lib/starknet/metadata.ts b/lib/starknet/metadata.ts new file mode 100644 index 0000000..cbdaa5f --- /dev/null +++ b/lib/starknet/metadata.ts @@ -0,0 +1,64 @@ +/** + * Deterministic commitment to an event's off-chain metadata. + * + * The Zicket catalogue lives in Postgres, but the contract stores a + * `metadata_hash` so anyone can verify the listing they are shown matches what + * the organizer published on-chain. The hash is computed over a canonical + * serialization, so the same ticket row always produces the same felt. + */ +import { poseidonHashMany } from "@scure/starknet"; + +import type { Ticket } from "@/lib/types"; + +/** Fields that are covered by the on-chain commitment. */ +export interface MetadataInput { + id: string; + title: string; + event_location: string; + event_date: number; + event_time_in_utc: string; + image: string; +} + +/** + * Splits UTF-8 bytes into 31-byte chunks (the largest that always fits in a + * felt252) and Poseidon-hashes the resulting span. + */ +function hashUtf8(value: string): bigint { + const bytes = new TextEncoder().encode(value); + const felts: bigint[] = []; + + for (let offset = 0; offset < bytes.length; offset += 31) { + const chunk = bytes.subarray(offset, offset + 31); + let acc = 0n; + for (const byte of chunk) acc = (acc << 8n) | BigInt(byte); + felts.push(acc); + } + + // Bind the length so "ab"+"c" and "a"+"bc" can never collide. + felts.push(BigInt(bytes.length)); + return poseidonHashMany(felts); +} + +export function computeMetadataHash(input: MetadataInput): string { + const canonical = JSON.stringify({ + id: input.id, + title: input.title, + location: input.event_location, + date: input.event_date, + time: input.event_time_in_utc, + image: input.image, + }); + return `0x${hashUtf8(canonical).toString(16)}`; +} + +export function metadataHashForTicket(ticket: Ticket): string { + return computeMetadataHash({ + id: ticket.id, + title: ticket.title, + event_location: ticket.event_location, + event_date: ticket.event_date, + event_time_in_utc: ticket.event_time_in_utc, + image: ticket.image, + }); +} diff --git a/lib/starknet/server.ts b/lib/starknet/server.ts new file mode 100644 index 0000000..504fa5e --- /dev/null +++ b/lib/starknet/server.ts @@ -0,0 +1,140 @@ +import "server-only"; + +/** + * Server-side Starknet access. + * + * Holds the platform account used to publish listings on behalf of organizers + * (a convenience so hosts don't need a funded wallet to list an event) and to + * verify transactions submitted by users. The private key never leaves the + * server — nothing in this module may be imported from a client component. + */ +import { Account, RpcProvider } from "starknet"; + +import { STARKNET_RPC_URL, ZICKET_CONTRACT_ADDRESS } from "@/lib/starknet/config"; +import { getZicketContract, type OnchainEvent, readEvent } from "@/lib/starknet/zicket"; + +let cachedProvider: RpcProvider | undefined; +let cachedAdmin: Account | undefined; + +export function getServerProvider(): RpcProvider { + cachedProvider ??= new RpcProvider({ + nodeUrl: process.env.STARKNET_RPC_URL ?? STARKNET_RPC_URL, + }); + return cachedProvider; +} + +/** True when the server is able to submit transactions itself. */ +export function hasAdminAccount(): boolean { + return Boolean( + process.env.STARKNET_ADMIN_ADDRESS && process.env.STARKNET_ADMIN_PRIVATE_KEY, + ); +} + +export function getAdminAccount(): Account { + if (cachedAdmin) return cachedAdmin; + + const address = process.env.STARKNET_ADMIN_ADDRESS; + const privateKey = process.env.STARKNET_ADMIN_PRIVATE_KEY; + if (!address || !privateKey) { + throw new Error( + "STARKNET_ADMIN_ADDRESS / STARKNET_ADMIN_PRIVATE_KEY are not set. " + + "Run `pnpm chain:deploy` to populate .env.local.", + ); + } + + cachedAdmin = new Account({ + provider: getServerProvider(), + address, + signer: privateKey, + }); + return cachedAdmin; +} + +export function getServerZicketContract(withAdmin = false) { + return getZicketContract(withAdmin ? getAdminAccount() : getServerProvider()); +} + +export async function readEventFromServer(eventId: number): Promise { + return readEvent(eventId, getServerZicketContract()); +} + +export interface VerifiedPurchase { + onchainTicketId: number; + eventId: number; + price: bigint; + /** Present for public purchases. */ + buyer: string | null; + /** Present for anonymous purchases. */ + commitment: string | null; + mode: "public" | "anonymous"; +} + +const EVENT_PREFIX = "zicket::zicket_events::ZicketEvents::"; + +/** + * Confirms a purchase actually happened on-chain before it is trusted in the + * database. A client could otherwise POST an arbitrary tx hash; here the + * receipt is accepted only if the transaction succeeded and our contract + * emitted a purchase event in it. + */ +export async function verifyPurchaseTx(txHash: string): Promise { + const provider = getServerProvider(); + const receipt = await provider.waitForTransaction(txHash); + if (!receipt.isSuccess()) return null; + + const zicketAddress = BigInt(ZICKET_CONTRACT_ADDRESS); + const rawEvents = + (receipt as unknown as { events?: { from_address: string }[] }).events ?? []; + if (!rawEvents.some((event) => BigInt(event.from_address) === zicketAddress)) { + return null; + } + + const parsed = getServerZicketContract().parseEvents(receipt as never); + + for (const entry of parsed) { + const anonymous = entry[`${EVENT_PREFIX}AnonymousTicketPurchased`] as + | Record + | undefined; + if (anonymous) { + return { + onchainTicketId: Number(anonymous.ticket_id), + eventId: Number(anonymous.event_id), + price: BigInt(anonymous.price), + buyer: null, + commitment: `0x${BigInt(anonymous.commitment).toString(16)}`, + mode: "anonymous", + }; + } + + const publicPurchase = entry[`${EVENT_PREFIX}TicketPurchased`] as + | Record + | undefined; + if (publicPurchase) { + return { + onchainTicketId: Number(publicPurchase.ticket_id), + eventId: Number(publicPurchase.event_id), + price: BigInt(publicPurchase.price), + buyer: `0x${BigInt(publicPurchase.buyer).toString(16)}`, + commitment: null, + mode: "public", + }; + } + } + + return null; +} + +/** Extracts the new event id from a `create_event` receipt. */ +export async function eventIdFromReceipt(txHash: string): Promise { + const provider = getServerProvider(); + const receipt = await provider.waitForTransaction(txHash); + if (!receipt.isSuccess()) return null; + + for (const entry of getServerZicketContract().parseEvents(receipt as never)) { + const created = entry[`${EVENT_PREFIX}EventCreated`] as + | Record + | undefined; + if (created) return Number(created.event_id); + } + return null; +} diff --git a/lib/starknet/use-ticket-purchase.ts b/lib/starknet/use-ticket-purchase.ts new file mode 100644 index 0000000..07abec7 --- /dev/null +++ b/lib/starknet/use-ticket-purchase.ts @@ -0,0 +1,180 @@ +"use client"; + +/** + * Drives a ticket purchase from the browser: resolves the on-chain listing, + * builds the approve + buy multicall, submits it through the connected wallet, + * and records the confirmed purchase against the backend. + */ +import { useCallback, useState } from "react"; + +import { RpcProvider } from "starknet"; + +import { useWallet } from "@/components/web/wallet-provider"; +import { + generateTicketSecret, + storeTicketSecret, + type TicketSecret, +} from "@/lib/starknet/commitment"; +import { STARKNET_RPC_URL } from "@/lib/starknet/config"; +import { buildPurchaseCalls } from "@/lib/starknet/zicket"; +import type { Ticket } from "@/lib/types"; + +export type PurchaseStage = + | "idle" + | "preparing" + | "awaiting-signature" + | "confirming" + | "recording" + | "done" + | "error"; + +export interface PurchaseResult { + txHash: string; + mode: "public" | "anonymous"; + /** Only present for anonymous purchases — this is the attendee's ticket. */ + secret?: TicketSecret; +} + +interface ChainEventResponse { + published: boolean; + event?: { price: string; cancelled: boolean; anonymousAllowed: boolean }; + onchainEventId?: number; + saleOpen?: boolean; + soldOut?: boolean; + error?: string; +} + +/** Resolves the on-chain listing, publishing it on first purchase if needed. */ +async function resolveOnchainEvent( + ticket: Ticket, +): Promise<{ eventId: number; price: bigint }> { + const read = async (): Promise => { + const response = await fetch(`/api/chain/events/${ticket.id}`); + return (await response.json()) as ChainEventResponse; + }; + + let state = await read(); + + if (!state.published) { + const publish = await fetch(`/api/chain/events/${ticket.id}`, { method: "POST" }); + const body = (await publish.json()) as { error?: string }; + if (!publish.ok) { + throw new Error(body.error ?? "This event is not available on-chain yet."); + } + state = await read(); + } + + if (!state.published || !state.event) { + throw new Error(state.error ?? "This event is not available on-chain yet."); + } + if (state.event.cancelled) { + throw new Error("This event has been cancelled."); + } + if (state.soldOut) { + throw new Error("This event is sold out."); + } + if (state.saleOpen === false) { + throw new Error("Ticket sales for this event have closed."); + } + + const eventId = state.onchainEventId ?? ticket.onchain_event_id; + if (!eventId) throw new Error("Missing on-chain event id."); + + return { eventId, price: BigInt(state.event.price) }; +} + +export function useTicketPurchase(ticket: Ticket) { + const { account, status } = useWallet(); + const [stage, setStage] = useState("idle"); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + const purchase = useCallback( + async (options?: { email?: string }) => { + if (!account || status !== "connected") { + setError("Connect a Starknet wallet to continue."); + setStage("error"); + return; + } + + setError(null); + setResult(null); + setStage("preparing"); + + const anonymous = Boolean(ticket.anonymous); + let secret: TicketSecret | undefined; + + try { + const { eventId, price } = await resolveOnchainEvent(ticket); + + if (anonymous) { + secret = generateTicketSecret(); + // Persisted *before* the transaction: if the tab dies between signing + // and confirmation the attendee can still redeem, because the secret + // is the only thing that proves ownership and it exists nowhere else. + storeTicketSecret({ + ...secret, + eventId: ticket.id, + onchainEventId: eventId, + createdAt: Date.now(), + }); + } + + const calls = buildPurchaseCalls({ + eventId, + price, + anonymous, + commitment: secret?.commitment, + }); + + setStage("awaiting-signature"); + const { transaction_hash: txHash } = await account.execute(calls); + + setStage("confirming"); + const provider = new RpcProvider({ nodeUrl: STARKNET_RPC_URL }); + await provider.waitForTransaction(txHash); + + if (secret) { + storeTicketSecret({ + ...secret, + eventId: ticket.id, + onchainEventId: eventId, + txHash, + createdAt: Date.now(), + }); + } + + setStage("recording"); + const record = await fetch("/api/chain/purchases", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ticketId: ticket.id, + txHash, + // An email is only ever attached to a non-anonymous purchase. + email: anonymous ? undefined : options?.email, + }), + }); + + if (!record.ok) { + const body = (await record.json()) as { error?: string }; + throw new Error(body.error ?? "The purchase could not be recorded."); + } + + setResult({ txHash, mode: anonymous ? "anonymous" : "public", secret }); + setStage("done"); + } catch (cause) { + const message = (cause as Error).message ?? "The purchase failed."; + setError( + secret + ? `${message} Your ticket secret was saved in this browser and can still be redeemed if the transaction succeeded.` + : message, + ); + setStage("error"); + } + }, + [account, status, ticket], + ); + + return { purchase, stage, error, result, isBusy: stage !== "idle" && stage !== "done" && stage !== "error" }; +} diff --git a/lib/starknet/zicket.ts b/lib/starknet/zicket.ts new file mode 100644 index 0000000..654346e --- /dev/null +++ b/lib/starknet/zicket.ts @@ -0,0 +1,258 @@ +/** + * Typed access to the deployed `ZicketEvents` contract. + * + * Read paths work with a plain RPC provider (no wallet required), so server + * components and API routes can render on-chain state. Write paths are exposed + * as `Call[]` builders that a connected wallet — or the server relayer in + * `lib/starknet/server.ts` — submits. + */ +import { CallData, Contract, RpcProvider, cairo, type Call } from "starknet"; + +import mockErc20Abi from "@/lib/starknet/abis/mock-erc20.json"; +import zicketAbi from "@/lib/starknet/abis/zicket-events.json"; +import { + PAYMENT_TOKEN_ADDRESS, + STARKNET_RPC_URL, + ZICKET_CONTRACT_ADDRESS, +} from "@/lib/starknet/config"; + +export type TicketModeName = "Public" | "Anonymous"; + +export interface OnchainEvent { + eventId: number; + organizer: string; + metadataHash: string; + price: bigint; + maxAttendees: number; + ticketsSold: number; + startTime: number; + endTime: number; + anonymousAllowed: boolean; + cancelled: boolean; + escrow: bigint; + withdrawn: boolean; +} + +export interface OnchainTicket { + ticketId: number; + eventId: number; + owner: string; + commitment: string; + mode: TicketModeName; + paid: bigint; + purchasedAt: number; + checkedIn: boolean; + refunded: boolean; +} + +let cachedProvider: RpcProvider | undefined; + +export function getProvider(): RpcProvider { + cachedProvider ??= new RpcProvider({ nodeUrl: STARKNET_RPC_URL }); + return cachedProvider; +} + +export function getZicketContract(providerOrAccount = getProvider()): Contract { + if (!ZICKET_CONTRACT_ADDRESS) { + throw new Error( + "NEXT_PUBLIC_ZICKET_CONTRACT_ADDRESS is not set. Run `pnpm chain:deploy`.", + ); + } + return new Contract({ + abi: zicketAbi, + address: ZICKET_CONTRACT_ADDRESS, + providerOrAccount, + }); +} + +export function getPaymentTokenContract(providerOrAccount = getProvider()): Contract { + if (!PAYMENT_TOKEN_ADDRESS) { + throw new Error( + "NEXT_PUBLIC_PAYMENT_TOKEN_ADDRESS is not set. Run `pnpm chain:deploy`.", + ); + } + return new Contract({ + abi: mockErc20Abi, + address: PAYMENT_TOKEN_ADDRESS, + providerOrAccount, + }); +} + +// ── normalisation ──────────────────────────────────────────────────────────── + +function toHex(value: unknown): string { + return `0x${BigInt(value as string | bigint | number).toString(16)}`; +} + +function toNumber(value: unknown): number { + return Number(BigInt(value as string | bigint | number)); +} + +/** + * starknet.js decodes a unit-only Cairo enum either as a `CairoCustomEnum` + * (with `activeVariant()`) or as the raw variant index, depending on the parser + * in play. Handle both so a starknet.js minor bump can't break check-in. + */ +function toTicketMode(value: unknown): TicketModeName { + if (value && typeof (value as { activeVariant?: unknown }).activeVariant === "function") { + return (value as { activeVariant: () => string }).activeVariant() as TicketModeName; + } + if (typeof value === "object" && value !== null) { + if ("Anonymous" in value) return "Anonymous"; + if ("Public" in value) return "Public"; + } + return toNumber(value) === 1 ? "Anonymous" : "Public"; +} + +export async function readEvent( + eventId: number, + contract = getZicketContract(), +): Promise { + const raw = (await contract.get_event(eventId)) as Record; + return { + eventId, + organizer: toHex(raw.organizer), + metadataHash: toHex(raw.metadata_hash), + price: BigInt(raw.price as bigint), + maxAttendees: toNumber(raw.max_attendees), + ticketsSold: toNumber(raw.tickets_sold), + startTime: toNumber(raw.start_time), + endTime: toNumber(raw.end_time), + anonymousAllowed: Boolean(raw.anonymous_allowed), + cancelled: Boolean(raw.cancelled), + escrow: BigInt(raw.escrow as bigint), + withdrawn: Boolean(raw.withdrawn), + }; +} + +export async function readTicket( + ticketId: number, + contract = getZicketContract(), +): Promise { + const raw = (await contract.get_ticket(ticketId)) as Record; + return { + ticketId, + eventId: toNumber(raw.event_id), + owner: toHex(raw.owner), + commitment: toHex(raw.commitment), + mode: toTicketMode(raw.mode), + paid: BigInt(raw.paid as bigint), + purchasedAt: toNumber(raw.purchased_at), + checkedIn: Boolean(raw.checked_in), + refunded: Boolean(raw.refunded), + }; +} + +export async function ticketsRemaining( + eventId: number, + contract = getZicketContract(), +): Promise { + return toNumber(await contract.tickets_remaining(eventId)); +} + +export async function ticketOf( + eventId: number, + attendee: string, + contract = getZicketContract(), +): Promise { + return toNumber(await contract.ticket_of(eventId, attendee)); +} + +export async function ticketOfCommitment( + eventId: number, + commitment: string, + contract = getZicketContract(), +): Promise { + return toNumber(await contract.ticket_of_commitment(eventId, commitment)); +} + +export async function isNullifierUsed( + eventId: number, + nullifierHash: string, + contract = getZicketContract(), +): Promise { + return Boolean(await contract.is_nullifier_used(eventId, nullifierHash)); +} + +export async function eventsCount(contract = getZicketContract()): Promise { + return toNumber(await contract.events_count()); +} + +// ── write-path call builders ───────────────────────────────────────────────── + +/** + * ERC20 `approve` for the exact ticket price. Skipped by callers when the event + * is free, since the contract short-circuits the transfer in that case. + */ +export function buildApproveCall(amount: bigint): Call { + return { + contractAddress: PAYMENT_TOKEN_ADDRESS, + entrypoint: "approve", + calldata: CallData.compile({ + spender: ZICKET_CONTRACT_ADDRESS, + amount: cairo.uint256(amount), + }), + }; +} + +export function buildBuyTicketCall(eventId: number): Call { + return { + contractAddress: ZICKET_CONTRACT_ADDRESS, + entrypoint: "buy_ticket", + calldata: CallData.compile({ event_id: eventId }), + }; +} + +export function buildBuyTicketAnonymousCall(eventId: number, commitment: string): Call { + return { + contractAddress: ZICKET_CONTRACT_ADDRESS, + entrypoint: "buy_ticket_anonymous", + calldata: CallData.compile({ event_id: eventId, commitment }), + }; +} + +/** + * Full multicall for a purchase: approve (paid events only) followed by the + * matching buy entrypoint. Starknet executes these atomically, so an approval + * can never be left dangling. + */ +export function buildPurchaseCalls(params: { + eventId: number; + price: bigint; + anonymous: boolean; + commitment?: string; +}): Call[] { + const { eventId, price, anonymous, commitment } = params; + const calls: Call[] = []; + + if (price > 0n) calls.push(buildApproveCall(price)); + + if (anonymous) { + if (!commitment) throw new Error("An anonymous purchase requires a commitment."); + calls.push(buildBuyTicketAnonymousCall(eventId, commitment)); + } else { + calls.push(buildBuyTicketCall(eventId)); + } + + return calls; +} + +export function buildCheckInCall(ticketId: number): Call { + return { + contractAddress: ZICKET_CONTRACT_ADDRESS, + entrypoint: "check_in", + calldata: CallData.compile({ ticket_id: ticketId }), + }; +} + +export function buildCheckInAnonymousCall( + eventId: number, + secret: string, + nullifier: string, +): Call { + return { + contractAddress: ZICKET_CONTRACT_ADDRESS, + entrypoint: "check_in_anonymous", + calldata: CallData.compile({ event_id: eventId, secret, nullifier }), + }; +} diff --git a/lib/types.ts b/lib/types.ts index d519831..4571974 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -11,7 +11,30 @@ export interface Ticket { anonymous?: boolean, paid?: boolean, price_in_usd: number, - event_verified?: boolean + event_verified?: boolean, + /** Id of this listing inside the ZicketEvents contract; null until published. */ + onchain_event_id?: number | null, + /** The ZicketEvents deployment `onchain_event_id` was issued by. */ + onchain_contract_address?: string | null, + /** Poseidon commitment to the listing metadata, as published on-chain. */ + metadata_hash?: string | null, + organizer_address?: string | null, + publish_tx_hash?: string | null +} + +export type PurchaseMode = "public" | "anonymous"; + +export interface TicketPurchase { + id: number, + ticket_id: string, + onchain_event_id: number, + onchain_ticket_id: number | null, + mode: PurchaseMode, + commitment: string | null, + buyer_address: string | null, + tx_hash: string, + status: "pending" | "confirmed" | "failed", + created_at: string } export interface Attendee { diff --git a/package.json b/package.json index fb8f0bf..5e8fa81 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,22 @@ "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", - "db:seed": "tsx scripts/seed.ts" + "db:seed": "tsx scripts/seed.ts", + "contracts:build": "cd contracts && scarb build", + "contracts:test": "cd contracts && snforge test", + "contracts:abi": "tsx scripts/starknet/extract-abi.ts", + "chain:devnet": "docker run --rm -d --name zicket-devnet -p 5050:5050 shardlabs/starknet-devnet-rs:latest --seed 0 --state-archive-capacity full", + "chain:devnet:stop": "docker stop zicket-devnet", + "chain:deploy": "tsx scripts/starknet/deploy.ts", + "chain:e2e": "tsx scripts/starknet/e2e.ts", + "chain:flow": "tsx scripts/starknet/flow-e2e.ts" }, "dependencies": { "@base-ui/react": "^1.0.0", "@hugeicons/core-free-icons": "^2.0.0", "@hugeicons/react": "^1.1.1", "@neondatabase/serverless": "^1.0.2", + "@scure/starknet": "^2.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.1", @@ -24,7 +33,9 @@ "radix-ui": "^1.4.3", "react": "19.2.1", "react-dom": "19.2.1", + "server-only": "^0.0.1", "shadcn": "^3.6.1", + "starknet": "^8.9.2", "tailwind-merge": "^3.4.0", "tw-animate-css": "^1.4.0" }, @@ -33,6 +44,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "dotenv": "^17.4.2", "drizzle-kit": "^0.31.8", "eslint": "^9", "eslint-config-next": "16.0.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52a0127..599d3cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@neondatabase/serverless': specifier: ^1.0.2 version: 1.0.2 + '@scure/starknet': + specifier: ^2.2.0 + version: 2.2.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -41,9 +44,15 @@ importers: react-dom: specifier: 19.2.1 version: 19.2.1(react@19.2.1) + server-only: + specifier: ^0.0.1 + version: 0.0.1 shadcn: specifier: ^3.6.1 version: 3.6.1(@types/node@20.19.27)(typescript@5.9.3) + starknet: + specifier: ^8.9.2 + version: 8.9.2 tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -63,6 +72,9 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.3(@types/react@19.2.7) + dotenv: + specifier: ^17.4.2 + version: 17.4.2 drizzle-kit: specifier: ^0.31.8 version: 0.31.8 @@ -1072,14 +1084,34 @@ packages: resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} + '@noble/curves@1.7.0': + resolution: {integrity: sha512-UTMhXK9SeDhFJVrHeUJ5uZlI6ajXg10O6Ddocf9S6GjbSBVZsJo88HzKwXznNfGpMTRDyJkqMjNDPYgf0qFWnw==} + engines: {node: ^14.21.3 || >=16} + '@noble/curves@1.9.7': resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} engines: {node: ^14.21.3 || >=16} + '@noble/curves@2.2.0': + resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@1.6.0': + resolution: {integrity: sha512-YUULf0Uk4/mAA89w+k3+yUYh6NrEvxZa5T6SY3wlMvE2chHkxFUUIDI8/XW1QSC357iA5pSnqt7XEhvFOqmDyQ==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.6.1': + resolution: {integrity: sha512-pq5D8h10hHBjyqX+cfBm0i8JUXJ0UhczFc4r74zbuT9XgewFo2E3J1cOaGtdZynILNmQ685YWGzGE1Zv6io50w==} + engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1798,6 +1830,16 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@scure/base@1.2.6': + resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + + '@scure/starknet@1.1.0': + resolution: {integrity: sha512-83g3M6Ix2qRsPN4wqLDqiRZ2GBNbjVWfboJE/9UjfG+MHr6oDSu/CWgy8hsBSJejr09DkkL+l0Ze4KVrlCIdtQ==} + + '@scure/starknet@2.2.0': + resolution: {integrity: sha512-FPgUFyEgbEG7ewrj9gWUeXpf2b6klBnqcZOSFz6z4ycVCIAeGhNqKQUPlvl2Ka8gfmrbCcdIrImyi+dINr06Uw==} + engines: {node: '>= 20.19.0'} + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -1805,6 +1847,12 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@starknet-io/types-js@0.8.4': + resolution: {integrity: sha512-0RZ3TZHcLsUTQaq1JhDSCM8chnzO4/XNsSCozwDET64JK5bjFDIf2ZUkta+tl5Nlbf4usoU7uZiDI/Q57kt2SQ==} + + '@starknet-io/types-js@0.9.2': + resolution: {integrity: sha512-vWOc0FVSn+RmabozIEWcEny1I73nDGTvOrLYJsR1x7LGA3AZmqt4i/aW69o/3i2NN5CVP8Ok6G1ayRQJKye3Wg==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -2085,6 +2133,10 @@ packages: cpu: [x64] os: [win32] + abi-wan-kanabi@2.2.4: + resolution: {integrity: sha512-0aA81FScmJCPX+8UvkXLki3X1+yPQuWxEkqXBVKltgPAK79J+NB+Lp5DouMXa7L6f+zcRlIA/6XO7BN/q9fnvg==} + hasBin: true + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -2129,6 +2181,9 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansicolors@0.3.2: + resolution: {integrity: sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==} + ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} @@ -2255,6 +2310,10 @@ packages: caniuse-lite@1.0.30001760: resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} + cardinal@2.1.1: + resolution: {integrity: sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==} + hasBin: true + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2448,8 +2507,8 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} - dotenv@17.2.3: - resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} drizzle-kit@0.31.8: @@ -2879,6 +2938,10 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + fs-extra@11.3.2: resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} engines: {node: '>=14.14'} @@ -3405,6 +3468,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lossless-json@4.3.0: + resolution: {integrity: sha512-ToxOC+SsduRmdSuoLZLYAr5zy1Qu7l5XhmPWM3zefCZ5IcrzW/h108qbJUKfOlDlhvhjUK84+8PSVX0kxnit0g==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3620,6 +3686,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@2.2.0: + resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3819,6 +3888,9 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + redeyed@2.1.1: + resolution: {integrity: sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -3911,6 +3983,9 @@ packages: resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} engines: {node: '>= 18'} + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -3982,6 +4057,10 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + starknet@8.9.2: + resolution: {integrity: sha512-+dp+o2w67fV6JyVOVkYeM1Ec71aORHc/JrF4VHLlfeGee0nLilooCQLE2u6hUcSGQG2x2/fvzkxYpIN+k1JBvA==} + engines: {node: '>=22'} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -4130,6 +4209,9 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-mixer@6.0.4: + resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} + ts-morph@26.0.0: resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} @@ -4581,7 +4663,7 @@ snapshots: '@dotenvx/dotenvx@1.51.2': dependencies: commander: 11.1.0 - dotenv: 17.2.3 + dotenv: 17.4.2 eciesjs: 0.4.16 execa: 5.1.1 fdir: 6.5.0(picomatch@4.0.3) @@ -5146,12 +5228,26 @@ snapshots: '@noble/ciphers@1.3.0': {} + '@noble/curves@1.7.0': + dependencies: + '@noble/hashes': 1.6.0 + '@noble/curves@1.9.7': dependencies: '@noble/hashes': 1.8.0 + '@noble/curves@2.2.0': + dependencies: + '@noble/hashes': 2.2.0 + + '@noble/hashes@1.6.0': {} + + '@noble/hashes@1.6.1': {} + '@noble/hashes@1.8.0': {} + '@noble/hashes@2.2.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -5924,10 +6020,26 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@scure/base@1.2.6': {} + + '@scure/starknet@1.1.0': + dependencies: + '@noble/curves': 1.7.0 + '@noble/hashes': 1.6.1 + + '@scure/starknet@2.2.0': + dependencies: + '@noble/curves': 2.2.0 + '@noble/hashes': 2.2.0 + '@sec-ant/readable-stream@0.4.1': {} '@sindresorhus/merge-streams@4.0.0': {} + '@starknet-io/types-js@0.8.4': {} + + '@starknet-io/types-js@0.9.2': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -6192,6 +6304,13 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + abi-wan-kanabi@2.2.4: + dependencies: + ansicolors: 0.3.2 + cardinal: 2.1.1 + fs-extra: 10.1.0 + yargs: 17.7.2 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -6231,6 +6350,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansicolors@0.3.2: {} + ansis@4.2.0: {} argparse@2.0.1: {} @@ -6392,6 +6513,11 @@ snapshots: caniuse-lite@1.0.30001760: {} + cardinal@2.1.1: + dependencies: + ansicolors: 0.3.2 + redeyed: 2.1.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -6540,7 +6666,7 @@ snapshots: dependencies: esutils: 2.0.3 - dotenv@17.2.3: {} + dotenv@17.4.2: {} drizzle-kit@0.31.8: dependencies: @@ -7150,6 +7276,12 @@ snapshots: fresh@2.0.0: {} + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + fs-extra@11.3.2: dependencies: graceful-fs: 4.2.11 @@ -7609,6 +7741,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lossless-json@4.3.0: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -7843,6 +7977,8 @@ snapshots: package-manager-detector@1.6.0: {} + pako@2.2.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -8069,6 +8205,10 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + redeyed@2.1.1: + dependencies: + esprima: 4.0.1 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -8188,6 +8328,8 @@ snapshots: transitivePeerDependencies: - supports-color + server-only@0.0.1: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -8336,6 +8478,19 @@ snapshots: stable-hash@0.0.5: {} + starknet@8.9.2: + dependencies: + '@noble/curves': 1.7.0 + '@noble/hashes': 1.6.1 + '@scure/base': 1.2.6 + '@scure/starknet': 1.1.0 + '@starknet-io/starknet-types-08': '@starknet-io/types-js@0.8.4' + '@starknet-io/starknet-types-09': '@starknet-io/types-js@0.9.2' + abi-wan-kanabi: 2.2.4 + lossless-json: 4.3.0 + pako: 2.2.0 + ts-mixer: 6.0.4 + statuses@2.0.2: {} stdin-discarder@0.2.2: {} @@ -8483,6 +8638,8 @@ snapshots: dependencies: typescript: 5.9.3 + ts-mixer@6.0.4: {} + ts-morph@26.0.0: dependencies: '@ts-morph/common': 0.27.0 diff --git a/scripts/seed.ts b/scripts/seed.ts index 56dd5f5..60f4073 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -1,4 +1,9 @@ -import "dotenv/config"; +import { config } from "dotenv"; + +config({ path: ".env.local", quiet: true }); +config({ quiet: true }); + +import { sql } from "drizzle-orm"; import { getDb } from "../src/index"; import { newsItems, tickets } from "../lib/mock_data"; @@ -39,6 +44,14 @@ async function seed() { // Tickets if (tickets.length) { + // The fixture dates are fixed points in the past. Rebasing them onto the + // current date keeps every seeded event upcoming, which is what the + // on-chain sale window requires — a listing whose window has closed cannot + // be published or bought. + const earliest = Math.min(...tickets.map((t) => t.event_date)); + const firstEventAt = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60; + const shift = firstEventAt - earliest; + await db .insert(ticketsTable) .values( @@ -49,7 +62,7 @@ async function seed() { image: t.image, noOfAttendees: t.no_of_attendees, attendees: t.attendees, - eventDate: t.event_date, + eventDate: t.event_date + shift, eventTimeInUtc: t.event_time_in_utc, eventLocation: t.event_location, anonymous: !!t.anonymous, @@ -58,7 +71,10 @@ async function seed() { eventVerified: !!t.event_verified, })) ) - .onConflictDoNothing(); + .onConflictDoUpdate({ + target: ticketsTable.id, + set: { eventDate: sql`excluded.event_date` }, + }); } // Newsletter: intentionally not seeded (real signups only) diff --git a/scripts/starknet/buyers.ts b/scripts/starknet/buyers.ts new file mode 100644 index 0000000..b4c6231 --- /dev/null +++ b/scripts/starknet/buyers.ts @@ -0,0 +1,155 @@ +/** + * Provisions the throwaway buyer accounts the e2e harness drives. + * + * On devnet these are simply two of the predeployed `--seed 0` accounts. On a + * public network no such accounts exist, so we derive a deterministic pair of + * OpenZeppelin accounts, persist their keys in `.env.local`, fund them with + * STRK out of the deployer, and counterfactually deploy them on first use. + */ +import { join } from "node:path"; + +import { Account, CallData, cairo, ec, hash, stark, type RpcProvider } from "starknet"; + +import { getDeployer, NETWORK, ROOT } from "./common"; +import { upsertEnvFile } from "./env"; + +/** A buyer wallet plus its key material, shaped like devnet's RPC response. */ +export interface BuyerWallet { + address: string; + private_key: string; +} + +/** Predeployed accounts #1 and #2 of `starknet-devnet --seed 0`. */ +const DEVNET_BUYERS: BuyerWallet[] = [ + { + address: "0x78662e7352d062084b0010068b99288486c2d8b914f6e2a55ce945f8792c8b1", + private_key: "0xe1406455b7d66b1690803be066cbe5e", + }, + { + address: "0x49dfb8ce986e21d354ac93ea65e6a11f639c1934ea253e5ff14ca62eca0f38e", + private_key: "0xa20a02f0ac53692d144b20cb371a60d7", + }, +]; + +/** + * OpenZeppelin account class (v0.8.1), already declared on Sepolia and mainnet. + * Its constructor is a single `public_key` felt and it accepts a plain + * `[r, s]` signature, which is what starknet.js's default signer produces. + * Overridable so the harness keeps working if the canonical class changes. + */ +const OZ_ACCOUNT_CLASS_HASH = + process.env.STARKNET_ACCOUNT_CLASS_HASH ?? + "0x061dac032f228abef9c6626f995015233097ae253a7f72d68552db02f2971b8f"; + +/** Canonical STRK on Sepolia and mainnet — the fee token. */ +const STRK_ADDRESS = + process.env.STARKNET_FEE_TOKEN_ADDRESS ?? + "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d"; + +/** Fee budget seeded into each buyer. Sepolia fees are a tiny fraction of this. */ +const FEE_FUNDING = BigInt(process.env.ZICKET_E2E_FEE_FUNDING ?? 3n * 10n ** 18n); + +const ENV_KEY = "ZICKET_E2E_BUYER_KEYS"; + +function loadOrCreateKeys(count: number): string[] { + const existing = (process.env[ENV_KEY] ?? "") + .split(",") + .map((k) => k.trim()) + .filter(Boolean); + + const keys = [...existing]; + while (keys.length < count) keys.push(stark.randomAddress()); + + if (keys.length !== existing.length) { + process.env[ENV_KEY] = keys.join(","); + upsertEnvFile(join(ROOT, ".env.local"), { [ENV_KEY]: keys.join(",") }); + } + return keys.slice(0, count); +} + +function deriveAddress(privateKey: string): { address: string; publicKey: string } { + const publicKey = ec.starkCurve.getStarkKey(privateKey); + const address = hash.calculateContractAddressFromHash( + publicKey, + OZ_ACCOUNT_CLASS_HASH, + CallData.compile({ publicKey }), + 0, + ); + return { address, publicKey }; +} + +async function isDeployed(provider: RpcProvider, address: string): Promise { + try { + await provider.getClassHashAt(address); + return true; + } catch { + return false; + } +} + +/** + * Returns `count` funded, deployed buyer wallets, including their key material. + * + * On devnet these are the predeployed `--seed 0` accounts. Elsewhere the keys + * are generated once, persisted to `.env.local`, and the accounts are funded + * with STRK and counterfactually deployed on first use. + */ +export async function provisionBuyerWallets( + provider: RpcProvider, + count = 2, +): Promise { + if (NETWORK === "devnet") { + if (count > DEVNET_BUYERS.length) { + throw new Error(`Only ${DEVNET_BUYERS.length} devnet buyers are configured`); + } + return DEVNET_BUYERS.slice(0, count); + } + + const { account: funder } = getDeployer(provider); + const keys = loadOrCreateKeys(count); + const wallets: BuyerWallet[] = []; + + for (const [index, privateKey] of keys.entries()) { + const { address, publicKey } = deriveAddress(privateKey); + + if (await isDeployed(provider, address)) { + console.log(` buyer ${index} ready ${address}`); + } else { + console.log(` buyer ${index} funding ${address}…`); + const { transaction_hash: fundTx } = await funder.execute({ + contractAddress: STRK_ADDRESS, + entrypoint: "transfer", + calldata: CallData.compile({ + recipient: address, + amount: cairo.uint256(FEE_FUNDING), + }), + }); + await funder.waitForTransaction(fundTx); + + console.log(` buyer ${index} deploying…`); + const account = new Account({ provider, address, signer: privateKey }); + const { transaction_hash: deployTx } = await account.deployAccount({ + classHash: OZ_ACCOUNT_CLASS_HASH, + constructorCalldata: CallData.compile({ publicKey }), + addressSalt: publicKey, + }); + await account.waitForTransaction(deployTx); + console.log(` buyer ${index} deployed ${address}`); + } + + wallets.push({ address, private_key: privateKey }); + } + + return wallets; +} + +/** Returns `count` funded, deployed accounts ready to send transactions. */ +export async function provisionBuyers( + provider: RpcProvider, + count = 2, +): Promise { + const wallets = await provisionBuyerWallets(provider, count); + return wallets.map( + (w) => new Account({ provider, address: w.address, signer: w.private_key }), + ); +} diff --git a/scripts/starknet/common.ts b/scripts/starknet/common.ts new file mode 100644 index 0000000..dfe3f80 --- /dev/null +++ b/scripts/starknet/common.ts @@ -0,0 +1,86 @@ +/** + * Shared bootstrap for the Starknet CLI scripts: resolves the RPC endpoint and + * the deployer account, with devnet defaults so `pnpm chain:deploy` works + * against a freshly started `shardlabs/starknet-devnet-rs` with no config. + */ +import { existsSync } from "node:fs"; +import { dirname, join, parse } from "node:path"; + +import { Account, RpcProvider } from "starknet"; + +import { loadEnvFile } from "./env"; + +/** + * Walks up from the cwd to the repo root. `import.meta.dirname` is unavailable + * because tsx transpiles these scripts to CJS. + */ +function findRepoRoot(start: string): string { + let current = start; + const { root } = parse(current); + while (true) { + if (existsSync(join(current, "pnpm-lock.yaml"))) return current; + if (current === root) throw new Error(`Could not find the repo root from ${start}`); + current = dirname(current); + } +} + +export const ROOT = findRepoRoot(process.cwd()); + +loadEnvFile(join(ROOT, ".env.local")); +loadEnvFile(join(ROOT, ".env")); + +/** Account #0 of `starknet-devnet --seed 0`. Local-only, never a real key. */ +const DEVNET_ACCOUNT = { + address: "0x64b48806902a367c8598f4f95c305e8c1a1acba5f082d294a43793113115691", + privateKey: "0x71d7bb07b9a64f6f78ac4c816aff4da9", +}; + +export const RPC_URL = + process.env.STARKNET_RPC_URL ?? + process.env.NEXT_PUBLIC_STARKNET_RPC_URL ?? + "http://127.0.0.1:5050"; + +export const NETWORK = process.env.NEXT_PUBLIC_STARKNET_NETWORK ?? "devnet"; + +export function getProvider(): RpcProvider { + return new RpcProvider({ nodeUrl: RPC_URL }); +} + +export interface DeployerAccount { + account: Account; + address: string; + privateKey: string; +} + +export function getDeployer(provider = getProvider()): DeployerAccount { + const address = process.env.STARKNET_DEPLOYER_ADDRESS ?? DEVNET_ACCOUNT.address; + const privateKey = + process.env.STARKNET_DEPLOYER_PRIVATE_KEY ?? DEVNET_ACCOUNT.privateKey; + + if (NETWORK !== "devnet" && !process.env.STARKNET_DEPLOYER_PRIVATE_KEY) { + throw new Error( + `Refusing to use the devnet key on "${NETWORK}". ` + + "Set STARKNET_DEPLOYER_ADDRESS and STARKNET_DEPLOYER_PRIVATE_KEY.", + ); + } + + return { + account: new Account({ provider, address, signer: privateKey }), + address, + privateKey, + }; +} + +/** Fails fast with a useful message when the node isn't reachable. */ +export async function assertNodeReachable(provider = getProvider()): Promise { + try { + await provider.getChainId(); + } catch (error) { + throw new Error( + `Cannot reach a Starknet node at ${RPC_URL}.\n` + + "Start a local devnet with:\n" + + " pnpm chain:devnet\n\n" + + `Underlying error: ${(error as Error).message}`, + ); + } +} diff --git a/scripts/starknet/deploy.ts b/scripts/starknet/deploy.ts new file mode 100644 index 0000000..e6e0657 --- /dev/null +++ b/scripts/starknet/deploy.ts @@ -0,0 +1,185 @@ +/** + * Declares and deploys the Zicket contracts, then writes the resulting + * addresses into `deployments/.json` and `.env.local`. + * + * Usage: + * pnpm chain:devnet # start a local node (docker) + * pnpm chain:deploy + * + * On devnet a `MockERC20` is deployed to act as the settlement token. On any + * other network set `PAYMENT_TOKEN_ADDRESS` to an existing ERC20 (e.g. STRK) + * and no mock is deployed. + */ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { CallData, type Account, type CompiledSierra } from "starknet"; + +import { assertNodeReachable, getDeployer, getProvider, NETWORK, ROOT, RPC_URL } from "./common"; +import { upsertEnvFile } from "./env"; + +const TARGET_DIR = join(ROOT, "contracts", "target", "dev"); + +const PLATFORM_FEE_BPS = Number(process.env.ZICKET_PLATFORM_FEE_BPS ?? 250); +const TOKEN_NAME = process.env.ZICKET_TOKEN_NAME ?? "Zicket USD"; +const TOKEN_SYMBOL = process.env.ZICKET_TOKEN_SYMBOL ?? "ZUSD"; +const TOKEN_DECIMALS = Number(process.env.ZICKET_TOKEN_DECIMALS ?? 18); +const TOKEN_SUPPLY = BigInt(process.env.ZICKET_TOKEN_SUPPLY ?? 10n ** 27n); +/** + * Opt-in to deploying `MockERC20` on a public network. Its `mint` is + * permissionless, so on a testnet it doubles as a self-service faucet. + */ +const DEPLOY_MOCK_TOKEN = process.env.ZICKET_DEPLOY_MOCK_TOKEN === "1"; + +interface Artifact { + sierra: CompiledSierra; + casm: unknown; + abi: unknown[]; +} + +function loadArtifact(name: string): Artifact { + const read = (suffix: string) => { + const path = join(TARGET_DIR, `zicket_${name}.${suffix}.json`); + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch { + throw new Error(`Missing ${path}. Run \`pnpm contracts:build\` first.`); + } + }; + + const sierra = read("contract_class") as CompiledSierra & { abi: unknown[] }; + return { sierra, casm: read("compiled_contract_class"), abi: sierra.abi }; +} + +async function declareAndDeploy( + account: Account, + name: string, + constructorCalldata: string[], +): Promise<{ classHash: string; address: string; txHash: string }> { + const { sierra, casm } = loadArtifact(name); + + process.stdout.write(` declaring ${name}… `); + const declared = await account.declareIfNot({ + contract: sierra, + casm: casm as never, + }); + if (declared.transaction_hash) { + await account.waitForTransaction(declared.transaction_hash); + console.log(`declared (${declared.class_hash.slice(0, 12)}…)`); + } else { + console.log(`already declared (${declared.class_hash.slice(0, 12)}…)`); + } + + process.stdout.write(` deploying ${name}… `); + const deployed = await account.deployContract({ + classHash: declared.class_hash, + constructorCalldata, + }); + await account.waitForTransaction(deployed.transaction_hash); + console.log(`at ${deployed.contract_address}`); + + return { + classHash: declared.class_hash, + address: deployed.contract_address, + txHash: deployed.transaction_hash, + }; +} + +async function main() { + const provider = getProvider(); + await assertNodeReachable(provider); + + const { account, address: deployerAddress } = getDeployer(provider); + const chainId = await provider.getChainId(); + + console.log(`\nZicket deployment`); + console.log(` network ${NETWORK}`); + console.log(` rpc ${RPC_URL}`); + console.log(` chain ${chainId}`); + console.log(` deployer ${deployerAddress}\n`); + + // 1. Settlement token ------------------------------------------------------ + let paymentToken = process.env.PAYMENT_TOKEN_ADDRESS ?? ""; + let tokenClassHash: string | null = null; + + if (paymentToken) { + console.log(` using existing payment token ${paymentToken}`); + } else if (NETWORK !== "devnet" && !DEPLOY_MOCK_TOKEN) { + throw new Error( + `PAYMENT_TOKEN_ADDRESS must be set for "${NETWORK}" — ` + + "the mock token is only deployed on devnet. Set ZICKET_DEPLOY_MOCK_TOKEN=1 " + + "to deploy it anyway as a public-faucet test token.", + ); + } else { + const { abi } = loadArtifact("MockERC20"); + const calldata = new CallData(abi as never).compile("constructor", { + name: TOKEN_NAME, + symbol: TOKEN_SYMBOL, + decimals: TOKEN_DECIMALS, + initial_supply: TOKEN_SUPPLY, + recipient: deployerAddress, + }); + const result = await declareAndDeploy(account, "MockERC20", calldata); + paymentToken = result.address; + tokenClassHash = result.classHash; + } + + // 2. Ticketing contract ---------------------------------------------------- + const feeRecipient = process.env.ZICKET_FEE_RECIPIENT ?? deployerAddress; + const { abi: zicketAbi } = loadArtifact("ZicketEvents"); + const zicketCalldata = new CallData(zicketAbi as never).compile("constructor", { + owner: deployerAddress, + payment_token: paymentToken, + fee_recipient: feeRecipient, + platform_fee_bps: PLATFORM_FEE_BPS, + }); + const zicket = await declareAndDeploy(account, "ZicketEvents", zicketCalldata); + + // 3. Persist --------------------------------------------------------------- + const deployment = { + network: NETWORK, + chainId, + rpcUrl: RPC_URL, + deployer: deployerAddress, + feeRecipient, + platformFeeBps: PLATFORM_FEE_BPS, + deployedAt: new Date().toISOString(), + contracts: { + ZicketEvents: { address: zicket.address, classHash: zicket.classHash }, + PaymentToken: { + address: paymentToken, + classHash: tokenClassHash, + symbol: tokenClassHash ? TOKEN_SYMBOL : undefined, + decimals: TOKEN_DECIMALS, + mock: Boolean(tokenClassHash), + }, + }, + }; + + const deploymentsDir = join(ROOT, "deployments"); + mkdirSync(deploymentsDir, { recursive: true }); + writeFileSync( + join(deploymentsDir, `${NETWORK}.json`), + `${JSON.stringify(deployment, null, 2)}\n`, + ); + + upsertEnvFile(join(ROOT, ".env.local"), { + NEXT_PUBLIC_STARKNET_NETWORK: NETWORK, + NEXT_PUBLIC_STARKNET_RPC_URL: RPC_URL, + NEXT_PUBLIC_ZICKET_CONTRACT_ADDRESS: zicket.address, + NEXT_PUBLIC_PAYMENT_TOKEN_ADDRESS: paymentToken, + NEXT_PUBLIC_PAYMENT_TOKEN_SYMBOL: tokenClassHash ? TOKEN_SYMBOL : "STRK", + NEXT_PUBLIC_PAYMENT_TOKEN_DECIMALS: String(TOKEN_DECIMALS), + STARKNET_RPC_URL: RPC_URL, + STARKNET_ADMIN_ADDRESS: deployerAddress, + STARKNET_ADMIN_PRIVATE_KEY: getDeployer(provider).privateKey, + }); + + console.log(`\n✓ deployment written to deployments/${NETWORK}.json`); + console.log(`✓ .env.local updated\n`); +} + +main().catch((error) => { + console.error(`\n✗ deploy failed: ${(error as Error).message}\n`); + process.exit(1); +}); diff --git a/scripts/starknet/e2e.ts b/scripts/starknet/e2e.ts new file mode 100644 index 0000000..87287ed --- /dev/null +++ b/scripts/starknet/e2e.ts @@ -0,0 +1,239 @@ +/** + * End-to-end exercise of the deployed contracts against a live node. + * + * Proves the whole ticketing loop works on chain, and — critically — that the + * commitment the browser computes in `lib/starknet/commitment.ts` is byte-for-byte + * the value the Cairo contract derives. If that ever drifts, anonymous tickets + * become unredeemable, so it is asserted first. + * + * Usage: pnpm chain:e2e + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { Account, CallData, Contract, cairo, type RpcProvider } from "starknet"; + +import { computeCommitment, computeNullifierHash, generateTicketSecret } from "../../lib/starknet/commitment"; +import { provisionBuyers } from "./buyers"; +import { assertNodeReachable, getDeployer, getProvider, NETWORK, ROOT } from "./common"; + + +const PRICE = 5n * 10n ** 18n; +const FUNDING = 1000n * 10n ** 18n; + +let checks = 0; +function assert(condition: boolean, message: string): void { + if (!condition) throw new Error(`assertion failed: ${message}`); + checks += 1; + console.log(` ✓ ${message}`); +} + +function loadAbi(name: string): unknown[] { + const path = join(ROOT, "contracts", "target", "dev", `zicket_${name}.contract_class.json`); + return JSON.parse(readFileSync(path, "utf8")).abi; +} + +function loadDeployment() { + const path = join(ROOT, "deployments", `${NETWORK}.json`); + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch { + throw new Error(`Missing ${path}. Run \`pnpm chain:deploy\` first.`); + } +} + +async function send(account: Account, calls: Parameters[0]) { + const { transaction_hash } = await account.execute(calls); + return account.waitForTransaction(transaction_hash); +} + +async function main() { + const provider: RpcProvider = getProvider(); + await assertNodeReachable(provider); + + const deployment = loadDeployment(); + const zicketAddress = deployment.contracts.ZicketEvents.address; + const tokenAddress = deployment.contracts.PaymentToken.address; + + const { account: admin } = getDeployer(provider); + const zicketAbi = loadAbi("ZicketEvents"); + const tokenAbi = loadAbi("MockERC20"); + + const zicket = new Contract({ abi: zicketAbi, address: zicketAddress, providerOrAccount: provider }); + const token = new Contract({ abi: tokenAbi, address: tokenAddress, providerOrAccount: provider }); + + console.log(`\nZicket e2e — ${NETWORK}`); + console.log(` zicket ${zicketAddress}`); + console.log(` token ${tokenAddress}\n`); + + // ── 0. Poseidon parity ───────────────────────────────────────────────────── + console.log("[0] commitment parity (JS ↔ Cairo)"); + const probe = generateTicketSecret(); + const onchainCommitment = BigInt( + (await zicket.compute_commitment(probe.secret, probe.nullifier)) as bigint, + ); + const onchainNullifierHash = BigInt( + (await zicket.compute_nullifier_hash(probe.nullifier)) as bigint, + ); + assert( + onchainCommitment === BigInt(probe.commitment), + "JS computeCommitment matches Cairo compute_commitment", + ); + assert( + onchainNullifierHash === BigInt(probe.nullifierHash), + "JS computeNullifierHash matches Cairo compute_nullifier_hash", + ); + + // ── 1. Fund buyers ───────────────────────────────────────────────────────── + console.log("\n[1] fund buyers"); + const buyers = await provisionBuyers(provider, 2); + + for (const buyer of buyers) { + await send(admin, { + contractAddress: tokenAddress, + entrypoint: "mint", + calldata: CallData.compile({ recipient: buyer.address, amount: cairo.uint256(FUNDING) }), + }); + } + const buyerBalance = BigInt((await token.balance_of(buyers[0].address)) as bigint); + assert(buyerBalance >= FUNDING, `buyer funded with ${buyerBalance / 10n ** 18n} tokens`); + + // ── 2. Create the event ──────────────────────────────────────────────────── + console.log("\n[2] create event"); + const now = Math.floor(Date.now() / 1000); + const startTime = now - 60; + const endTime = now + 86_400; + + await send(admin, { + contractAddress: zicketAddress, + entrypoint: "create_event", + calldata: CallData.compile({ + metadata_hash: "0x5a49434b4554", // "ZICKET" + price: cairo.uint256(PRICE), + max_attendees: 100, + start_time: startTime, + end_time: endTime, + anonymous_allowed: true, + }), + }); + + const eventId = Number(BigInt((await zicket.events_count()) as bigint)); + assert(eventId >= 1, `event created with id ${eventId}`); + + const created = (await zicket.get_event(eventId)) as Record; + assert(BigInt(created.price) === PRICE, "on-chain price matches"); + assert(Number(created.max_attendees) === 100, "capacity is 100"); + + // ── 3. Public purchase ───────────────────────────────────────────────────── + console.log("\n[3] public ticket"); + await send(buyers[0], [ + { + contractAddress: tokenAddress, + entrypoint: "approve", + calldata: CallData.compile({ spender: zicketAddress, amount: cairo.uint256(PRICE) }), + }, + { + contractAddress: zicketAddress, + entrypoint: "buy_ticket", + calldata: CallData.compile({ event_id: eventId }), + }, + ]); + + const publicTicketId = Number(BigInt((await zicket.ticket_of(eventId, buyers[0].address)) as bigint)); + assert(publicTicketId >= 1, `public ticket #${publicTicketId} bound to buyer`); + + const publicTicket = (await zicket.get_ticket(publicTicketId)) as Record; + assert( + BigInt(publicTicket.owner as bigint) === BigInt(buyers[0].address), + "public ticket owner is the buyer", + ); + assert(BigInt(publicTicket.paid as bigint) === PRICE, "public ticket recorded the price paid"); + + // ── 4. Anonymous purchase (relayed) ──────────────────────────────────────── + console.log("\n[4] anonymous ticket"); + const secret = generateTicketSecret(); + assert( + computeCommitment(secret.secret, secret.nullifier) === secret.commitment, + "commitment is reproducible from the stored secret pair", + ); + + // buyers[1] pays, but the ticket is bound only to the commitment. + await send(buyers[1], [ + { + contractAddress: tokenAddress, + entrypoint: "approve", + calldata: CallData.compile({ spender: zicketAddress, amount: cairo.uint256(PRICE) }), + }, + { + contractAddress: zicketAddress, + entrypoint: "buy_ticket_anonymous", + calldata: CallData.compile({ event_id: eventId, commitment: secret.commitment }), + }, + ]); + + const anonTicketId = Number( + BigInt((await zicket.ticket_of_commitment(eventId, secret.commitment)) as bigint), + ); + assert(anonTicketId >= 1, `anonymous ticket #${anonTicketId} indexed by commitment`); + + const anonTicket = (await zicket.get_ticket(anonTicketId)) as Record; + assert( + BigInt(anonTicket.owner as bigint) === 0n, + "anonymous ticket has no on-chain owner (payer is not the holder)", + ); + assert( + Number(BigInt((await zicket.ticket_of(eventId, buyers[1].address)) as bigint)) === 0, + "payer address is not indexed against the anonymous ticket", + ); + + // ── 5. Check-in ──────────────────────────────────────────────────────────── + console.log("\n[5] check-in"); + await send(buyers[0], { + contractAddress: zicketAddress, + entrypoint: "check_in", + calldata: CallData.compile({ ticket_id: publicTicketId }), + }); + const checkedIn = (await zicket.get_ticket(publicTicketId)) as Record; + assert(Boolean(checkedIn.checked_in), "public ticket checked in"); + + assert( + !(await zicket.is_nullifier_used(eventId, secret.nullifierHash)), + "nullifier unused before anonymous check-in", + ); + + // A third wallet that never touched the purchase redeems using only the secret. + await send(admin, { + contractAddress: zicketAddress, + entrypoint: "check_in_anonymous", + calldata: CallData.compile({ + event_id: eventId, + secret: secret.secret, + nullifier: secret.nullifier, + }), + }); + + assert( + Boolean(await zicket.is_nullifier_used(eventId, computeNullifierHash(secret.nullifier))), + "nullifier burned after anonymous check-in (double entry blocked)", + ); + + const anonAfter = (await zicket.get_ticket(anonTicketId)) as Record; + assert(Boolean(anonAfter.checked_in), "anonymous ticket checked in by an unrelated wallet"); + + // ── 6. Supply accounting ─────────────────────────────────────────────────── + console.log("\n[6] accounting"); + const finalEvent = (await zicket.get_event(eventId)) as Record; + assert(Number(finalEvent.tickets_sold as bigint) === 2, "tickets_sold is 2"); + assert(BigInt(finalEvent.escrow as bigint) === PRICE * 2n, "escrow holds both payments"); + assert( + Number(BigInt((await zicket.tickets_remaining(eventId)) as bigint)) === 98, + "98 tickets remaining", + ); + + console.log(`\n✓ ${checks} assertions passed — end-to-end flow verified on ${NETWORK}\n`); +} + +main().catch((error) => { + console.error(`\n✗ e2e failed: ${(error as Error).message}\n`); + process.exit(1); +}); diff --git a/scripts/starknet/env.ts b/scripts/starknet/env.ts new file mode 100644 index 0000000..1a9f03e --- /dev/null +++ b/scripts/starknet/env.ts @@ -0,0 +1,58 @@ +/** + * Minimal `.env` reader/writer used by the deploy scripts. + * + * Avoids a dotenv dependency: Next.js already loads `.env.local` for the app, + * and these scripts only need it for standalone CLI runs. + */ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; + +export function loadEnvFile(path: string): void { + if (!existsSync(path)) return; + + for (const line of readFileSync(path, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + // Real environment variables always win over the file. + process.env[key] ??= value; + } +} + +/** Inserts or replaces keys in an env file, preserving unrelated lines. */ +export function upsertEnvFile(path: string, values: Record): void { + const lines = existsSync(path) ? readFileSync(path, "utf8").split("\n") : []; + const remaining = new Map(Object.entries(values)); + + const next = lines.map((line) => { + const eq = line.indexOf("="); + if (eq === -1 || line.trim().startsWith("#")) return line; + + const key = line.slice(0, eq).trim(); + if (!remaining.has(key)) return line; + + const value = remaining.get(key) as string; + remaining.delete(key); + return `${key}=${value}`; + }); + + if (remaining.size > 0) { + if (next.length > 0 && next[next.length - 1].trim() !== "") next.push(""); + next.push("# --- Zicket on-chain deployment (written by pnpm chain:deploy) ---"); + for (const [key, value] of remaining) next.push(`${key}=${value}`); + next.push(""); + } + + writeFileSync(path, next.join("\n")); +} diff --git a/scripts/starknet/extract-abi.ts b/scripts/starknet/extract-abi.ts new file mode 100644 index 0000000..71d20bd --- /dev/null +++ b/scripts/starknet/extract-abi.ts @@ -0,0 +1,43 @@ +/** + * Copies the ABIs produced by `scarb build` into `lib/starknet/abis` so the web + * app never has to read from the Cairo build directory at runtime. + * + * Usage: pnpm contracts:abi + */ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, "..", ".."); +const TARGET_DIR = join(ROOT, "contracts", "target", "dev"); +const OUT_DIR = join(ROOT, "lib", "starknet", "abis"); + +const CONTRACTS = [ + { artifact: "zicket_ZicketEvents.contract_class.json", out: "zicket-events.json" }, + { artifact: "zicket_MockERC20.contract_class.json", out: "mock-erc20.json" }, +] as const; + +function main() { + mkdirSync(OUT_DIR, { recursive: true }); + + for (const { artifact, out } of CONTRACTS) { + const source = join(TARGET_DIR, artifact); + let raw: string; + try { + raw = readFileSync(source, "utf8"); + } catch { + throw new Error( + `Missing ${source}. Run \`pnpm contracts:build\` (scarb build) first.`, + ); + } + + const { abi } = JSON.parse(raw) as { abi: unknown[] }; + if (!Array.isArray(abi)) throw new Error(`No ABI array in ${artifact}`); + + writeFileSync(join(OUT_DIR, out), `${JSON.stringify(abi, null, 2)}\n`); + console.log(`✓ ${out} (${abi.length} entries)`); + } +} + +main(); diff --git a/scripts/starknet/flow-e2e.ts b/scripts/starknet/flow-e2e.ts new file mode 100644 index 0000000..de72834 --- /dev/null +++ b/scripts/starknet/flow-e2e.ts @@ -0,0 +1,296 @@ +/** + * Full-stack purchase regression test. + * + * Unlike `e2e.ts`, which talks to the contracts directly, this drives the + * running Next.js server: it publishes a listing through the API, performs the + * exact multicall the browser hook builds, and then asks the API to verify and + * record the purchase. It is the closest thing to a headless run of the UI. + * + * pnpm chain:flow # expects the app on http://localhost:3100 + */ +import { Account, RpcProvider, cairo, CallData } from "starknet"; + +import { computeCommitment, randomFelt } from "../../lib/starknet/commitment"; +import { provisionBuyerWallets } from "./buyers"; +// Importing common also loads .env.local / .env into process.env. +import { NETWORK, RPC_URL } from "./common"; + +const APP_URL = process.env.APP_URL ?? "http://localhost:3100"; + +/** + * Wallets to provision on a public network. Each costs a fund + deploy + * transaction on first run, and a public ticket is one-per-address, so this + * also caps how many times the harness can re-run before it needs more. + */ +const SHOPPER_COUNT = Number(process.env.ZICKET_FLOW_SHOPPERS ?? 3); + +let passed = 0; +let failed = 0; + +function check(label: string, condition: boolean, detail?: unknown) { + if (condition) { + passed += 1; + console.log(` ✓ ${label}`); + } else { + failed += 1; + console.error(` ✗ ${label}`, detail ?? ""); + } +} + +async function api(path: string, init?: RequestInit): Promise<{ status: number; body: T }> { + const response = await fetch(`${APP_URL}${path}`, { + ...init, + headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, + }); + return { status: response.status, body: (await response.json()) as T }; +} + +interface DevnetAccount { + address: string; + private_key: string; +} + +/** Devnet exposes its predeployed accounts over JSON-RPC, not REST. */ +async function devnetAccounts(rpcUrl: string): Promise { + const response = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "devnet_getPredeployedAccounts", + params: { with_balance: false }, + }), + }); + const body = (await response.json()) as { result?: DevnetAccount[] }; + return body.result ?? []; +} + +/** + * Stand-ins for shoppers' browser wallets. Devnet hands us a pool of ready + * accounts; on a public network we provision (fund + deploy) a small set. + */ +async function shopperWallets( + provider: RpcProvider, + rpcUrl: string, + adminAddress: string, +): Promise { + if (NETWORK === "devnet") { + return (await devnetAccounts(rpcUrl)).filter( + (w) => BigInt(w.address) !== BigInt(adminAddress), + ); + } + return provisionBuyerWallets(provider, SHOPPER_COUNT); +} + +/** A public ticket binds to one address, so re-runs need a fresh buyer. */ +async function firstWalletWithoutTicket( + provider: RpcProvider, + zicket: string, + eventId: number, + wallets: DevnetAccount[], +): Promise { + for (const wallet of wallets) { + const [held] = await provider.callContract({ + contractAddress: zicket, + entrypoint: "ticket_of", + calldata: CallData.compile({ event_id: eventId, account: wallet.address }), + }); + if (BigInt(held) === 0n) return wallet; + } + return undefined; +} + +async function main() { + const rpcUrl = RPC_URL; + const zicket = process.env.NEXT_PUBLIC_ZICKET_CONTRACT_ADDRESS!; + const token = process.env.NEXT_PUBLIC_PAYMENT_TOKEN_ADDRESS!; + const adminAddress = process.env.STARKNET_ADMIN_ADDRESS!; + const adminKey = process.env.STARKNET_ADMIN_PRIVATE_KEY!; + + const provider = new RpcProvider({ nodeUrl: rpcUrl }); + const admin = new Account({ provider, address: adminAddress, signer: adminKey }); + + // Devnet's predeployed accounts stand in for shoppers' browser wallets. A + // public ticket is one-per-address, so each run needs an address that does + // not already hold one — otherwise the contract correctly rejects the buy. + const wallets = await shopperWallets(provider, rpcUrl, adminAddress); + if (wallets.length === 0) throw new Error("No buyer accounts available"); + + console.log(`\nApp: ${APP_URL}`); + console.log(`RPC: ${rpcUrl}`); + console.log(`Zicket: ${zicket}\n`); + + // ── [0] the app is serving chain config ──────────────────────────────────── + console.log("[0] chain config"); + const config = await api<{ configured: boolean; contracts: { zicket: string } }>( + "/api/chain/config", + ); + check("config endpoint reports configured", config.body.configured === true); + check( + "config advertises the deployed contract", + BigInt(config.body.contracts.zicket) === BigInt(zicket), + ); + + // ── [1] fund the buyer ───────────────────────────────────────────────────── + console.log("\n[1] fund buyers with payment token"); + let mint = { transaction_hash: "" }; + for (const wallet of wallets) { + mint = await admin.execute({ + contractAddress: token, + entrypoint: "mint", + calldata: CallData.compile({ + recipient: wallet.address, + amount: cairo.uint256(10_000n * 10n ** 18n), + }), + }); + await provider.waitForTransaction(mint.transaction_hash); + } + check("buyers funded", Boolean(mint.transaction_hash)); + + // ── [2] publish through the API ──────────────────────────────────────────── + for (const [ticketId, label] of [ + ["234", "anonymous"], + ["235", "public"], + ] as const) { + console.log(`\n[2:${label}] publish ticket ${ticketId}`); + const publish = await api<{ published: boolean; error?: string }>( + `/api/chain/events/${ticketId}`, + { method: "POST" }, + ); + check(`publish ${ticketId} succeeded`, publish.status === 200, publish.body); + + const state = await api<{ + published: boolean; + saleOpen: boolean; + onchainEventId?: number; + event: { eventId: number; price: string; ticketsSold: number; anonymousAllowed: boolean }; + }>(`/api/chain/events/${ticketId}`); + const listing = await api<{ item: { no_of_attendees: number } }>(`/api/tickets/${ticketId}`); + const attendeesBefore = listing.body.item.no_of_attendees; + check(`${ticketId} is on-chain`, state.body.published === true); + check(`${ticketId} sale is open`, state.body.saleOpen === true); + + const eventId = state.body.event.eventId; + const price = BigInt(state.body.event.price); + const soldBefore = state.body.event.ticketsSold; + const anonymous = label === "anonymous"; + check( + `${ticketId} anonymity flag matches the listing`, + state.body.event.anonymousAllowed === anonymous, + ); + + // ── [3] the multicall the browser signs ────────────────────────────────── + console.log(`[3:${label}] purchase`); + const wallet = anonymous + ? wallets[0] + : await firstWalletWithoutTicket(provider, zicket, eventId, wallets); + if (!wallet) throw new Error(`No devnet account left without a ticket for event ${eventId}`); + const buyer = new Account({ + provider, + address: wallet.address, + signer: wallet.private_key, + }); + console.log(` buyer ${wallet.address}`); + const secret = anonymous ? randomFelt() : undefined; + const nullifier = anonymous ? randomFelt() : undefined; + const commitment = + secret && nullifier ? computeCommitment(secret, nullifier) : undefined; + + const calls = [ + ...(price > 0n + ? [ + { + contractAddress: token, + entrypoint: "approve", + calldata: CallData.compile({ spender: zicket, amount: cairo.uint256(price) }), + }, + ] + : []), + { + contractAddress: zicket, + entrypoint: anonymous ? "buy_ticket_anonymous" : "buy_ticket", + calldata: anonymous + ? CallData.compile({ event_id: eventId, commitment: commitment! }) + : CallData.compile({ event_id: eventId }), + }, + ]; + + const tx = await buyer.execute(calls); + await provider.waitForTransaction(tx.transaction_hash); + check(`${label} purchase transaction accepted`, Boolean(tx.transaction_hash)); + + // ── [4] the API verifies the transaction on-chain and records it ───────── + console.log(`[4:${label}] record purchase`); + const record = await api<{ purchase?: { mode: string }; created?: boolean; error?: string }>( + "/api/chain/purchases", + { + method: "POST", + body: JSON.stringify({ + ticketId, + txHash: tx.transaction_hash, + email: anonymous ? undefined : "buyer@example.com", + }), + }, + ); + check(`${label} purchase recorded`, record.status === 201, record.body); + check(`${label} recorded as newly created`, record.body.created === true, record.body); + check(`${label} recorded with the right mode`, record.body.purchase?.mode === label, record.body); + + // Replaying the same hash must not create a second row. + const replay = await api<{ created?: boolean }>("/api/chain/purchases", { + method: "POST", + body: JSON.stringify({ ticketId, txHash: tx.transaction_hash }), + }); + check(`${label} replay is accepted`, replay.status === 200, replay.body); + check(`${label} replay is not a new purchase`, replay.body.created === false, replay.body); + + const listingAfter = await api<{ item: { no_of_attendees: number } }>( + `/api/tickets/${ticketId}`, + ); + check( + `${label} replay did not inflate the attendee count`, + listingAfter.body.item.no_of_attendees === attendeesBefore + 1, + { before: attendeesBefore, after: listingAfter.body.item.no_of_attendees }, + ); + + const listed = await api<{ items: Array<{ tx_hash: string; mode: string }> }>( + `/api/chain/purchases?ticketId=${ticketId}`, + ); + const matches = listed.body.items.filter( + (p) => BigInt(p.tx_hash) === BigInt(tx.transaction_hash), + ); + check(`${label} stored exactly once`, matches.length === 1, listed.body.items); + + // ── [5] chain state moved ──────────────────────────────────────────────── + const after = await api<{ event: { ticketsSold: number; escrow: string } }>( + `/api/chain/events/${ticketId}`, + ); + check( + `${label} tickets_sold incremented`, + after.body.event.ticketsSold === soldBefore + 1, + after.body.event, + ); + check( + `${label} escrow holds the ticket price`, + BigInt(after.body.event.escrow) >= price, + after.body.event.escrow, + ); + } + + // ── [6] a forged transaction hash is rejected ────────────────────────────── + console.log("\n[6] rejects an unrelated transaction"); + const forged = await api<{ error?: string }>("/api/chain/purchases", { + method: "POST", + body: JSON.stringify({ ticketId: "234", txHash: mint.transaction_hash }), + }); + check("mint tx is not accepted as a purchase", forged.status >= 400, forged.body); + + console.log(`\n${failed === 0 ? "✅" : "❌"} ${passed} passed, ${failed} failed\n`); + if (failed > 0) process.exit(1); +} + +main().catch((error) => { + console.error("\n❌ flow-e2e failed\n", error); + process.exit(1); +}); diff --git a/src/db/schema.ts b/src/db/schema.ts index cce4e7c..4a84513 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,15 @@ -import { boolean, integer, jsonb, numeric, pgTable, text, timestamp, varchar } from "drizzle-orm/pg-core"; +import { + boolean, + index, + integer, + jsonb, + numeric, + pgTable, + text, + timestamp, + uniqueIndex, + varchar, +} from "drizzle-orm/pg-core"; export const newsletterSubscribers = pgTable("newsletter_subscribers", { id: integer().primaryKey().generatedAlwaysAsIdentity(), @@ -35,5 +46,62 @@ export const tickets = pgTable("tickets", { paid: boolean().notNull().default(false), priceInUsd: numeric("price_in_usd", { precision: 10, scale: 2 }).notNull(), eventVerified: boolean("event_verified").notNull().default(false), + + // ── On-chain linkage ────────────────────────────────────────────────────── + // Null until the listing has been published to the ZicketEvents contract by + // POST /api/chain/events/[id]/publish. + onchainEventId: integer("onchain_event_id"), + /** + * The ZicketEvents deployment `onchain_event_id` belongs to. Event ids restart + * at 1 for every deployment, so without this a listing published against one + * contract would be read back against another (e.g. after switching from + * devnet to sepolia) and silently resolve to an unrelated or empty event. + */ + onchainContractAddress: varchar("onchain_contract_address", { length: 66 }), + /** Poseidon commitment to the immutable listing fields (lib/starknet/metadata.ts). */ + metadataHash: varchar("metadata_hash", { length: 66 }), + organizerAddress: varchar("organizer_address", { length: 66 }), + publishTxHash: varchar("publish_tx_hash", { length: 66 }), }); +/** + * A purchase recorded against the chain. + * + * Public purchases store the buyer address and the on-chain ticket id. + * Anonymous purchases store *only* the commitment — never the secret, the + * nullifier, or the payer. That is the whole point: the backend must not be + * able to link an attendee to an event, so the row is deliberately unable to + * identify anyone. The secret lives in the attendee's browser. + */ +export const ticketPurchases = pgTable( + "ticket_purchases", + { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + ticketId: varchar("ticket_id", { length: 64 }) + .notNull() + .references(() => tickets.id, { onDelete: "cascade" }), + onchainEventId: integer("onchain_event_id").notNull(), + onchainTicketId: integer("onchain_ticket_id"), + /** "public" | "anonymous" */ + mode: varchar({ length: 16 }).notNull().default("public"), + /** Set for anonymous purchases only. */ + commitment: varchar({ length: 66 }), + /** Set for public purchases only. */ + buyerAddress: varchar("buyer_address", { length: 66 }), + txHash: varchar("tx_hash", { length: 66 }).notNull(), + /** "pending" | "confirmed" | "failed" */ + status: varchar({ length: 16 }).notNull().default("pending"), + /** Optional reminder address. Never collected for anonymous purchases. */ + email: varchar({ length: 255 }), + createdAt: timestamp("created_at", { withTimezone: true, mode: "string" }) + .notNull() + .defaultNow(), + }, + (table) => [ + uniqueIndex("ticket_purchases_tx_hash_idx").on(table.txHash), + index("ticket_purchases_ticket_id_idx").on(table.ticketId), + index("ticket_purchases_commitment_idx").on(table.commitment), + ], +); + + diff --git a/src/index.ts b/src/index.ts index c9ad8c1..36ef5b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { neon } from "@neondatabase/serverless"; +import { neon, neonConfig } from "@neondatabase/serverless"; import { drizzle } from "drizzle-orm/neon-http"; import * as schema from "./db/schema"; @@ -7,6 +7,20 @@ type Db = ReturnType>; let _db: Db | null = null; +/** + * Points the Neon HTTP driver at a local proxy when NEON_HTTP_ENDPOINT is set, + * so the app can run against a plain Postgres container in development. + */ +function configureLocalEndpoint() { + const endpoint = process.env.NEON_HTTP_ENDPOINT; + if (!endpoint) return; + + const url = new URL(endpoint); + neonConfig.fetchEndpoint = endpoint; + neonConfig.useSecureWebSocket = url.protocol === "https:"; + neonConfig.poolQueryViaFetch = true; +} + export function getDb(): Db { if (_db) return _db; @@ -15,6 +29,8 @@ export function getDb(): Db { throw new Error("Missing DATABASE_URL. Set it in your environment (Neon connection string)."); } + configureLocalEndpoint(); + const sql = neon(DATABASE_URL); _db = drizzle({ client: sql, schema }); return _db; diff --git a/tsconfig.json b/tsconfig.json index 3a13f90..15c7b97 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "ES2017", + "target": "ES2020", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true,