From 8bd2c7254e0467a1849cfe31476d4ec775740aa0 Mon Sep 17 00:00:00 2001 From: wheval Date: Wed, 29 Jul 2026 03:25:21 +0100 Subject: [PATCH 1/5] Add Starknet ticketing contracts and wire them through the app Zicket had Starknet and zk-verification trust markers on the homepage but no on-chain layer, and the "Get Ticket" button did nothing. This adds the missing layer end to end. Contracts (contracts/, Cairo 2.14 + snforge): ZicketEvents supports two kinds of ticket. A public ticket binds to the buyer's address, one per wallet. An anonymous ticket is a commitment: the buyer computes poseidon(secret, nullifier) in the browser and only that reaches the chain, so the ticket has no owner at all. Check-in reveals the preimage and burns poseidon(nullifier), which is what stops a ticket being used twice without ever identifying who held it. Also covers ERC-20 payment, per-event escrow with organizer withdrawal, a platform fee in bps, and a cancel/refund path. MockERC20 provides a payment token for local runs. 36 snforge tests. Backend: tickets gains its on-chain columns and a new ticket_purchases table keyed on the transaction hash. POST /api/chain/purchases accepts only a tx hash and reads every other field 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. Listings are published on-chain by a server relayer, so an organizer does not need a funded wallet to list. Frontend: Wallet connection is built on starknet.js's own WalletAccount plus SNIP-1193 discovery. get-starknet-core was tried and rejected: its current release still targets the starknet v5 API and does not work against v8. The purchase flow writes an anonymous ticket's secret to localStorage before the transaction is signed, because the secret is the only proof of ownership and exists nowhere else -- if the tab dies mid-flight the attendee can still redeem. The UI says plainly that clearing site data loses the ticket. Notes: Seeded events are rebased onto the current date. Every fixture date was in the past, which made all of them unpurchasable once the sale window was enforced. Recording a purchase now reports whether the row was new, because retrying the same transaction hash was inflating the attendee count. Covered by a test. src/index.ts can point the Neon driver at a local HTTP proxy, so the stack runs against a plain Postgres container; unset in production. Verified against starknet-devnet in Docker: 36 contract tests, 20 assertions directly against the deployed contracts, and 32 through the running app. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .gitignore | 4 + README.md | 87 +++ app/api/chain/config/route.ts | 31 + app/api/chain/events/[id]/route.ts | 148 ++++ app/api/chain/purchases/route.ts | 116 +++ app/api/tickets/[id]/route.ts | 3 + app/api/tickets/route.ts | 3 + app/layout.tsx | 13 +- components/web/connect-wallet-button.tsx | 106 +++ components/web/navbar.tsx | 3 + components/web/ticket-purchase-card.tsx | 79 +- components/web/wallet-provider.tsx | 196 +++++ contracts/Scarb.lock | 24 + contracts/Scarb.toml | 21 + contracts/src/interfaces.cairo | 78 ++ contracts/src/lib.cairo | 4 + contracts/src/mock_erc20.cairo | 172 ++++ contracts/src/types.cairo | 53 ++ contracts/src/zicket_events.cairo | 758 ++++++++++++++++++ contracts/tests/test_zicket.cairo | 628 +++++++++++++++ deployments/devnet.json | 22 + drizzle.config.ts | 6 +- drizzle/0000_wandering_raider.sql | 58 ++ drizzle/meta/0000_snapshot.json | 417 ++++++++++ drizzle/meta/_journal.json | 13 + lib/db/queries.ts | 149 +++- lib/starknet/abis/mock-erc20.json | 317 ++++++++ lib/starknet/abis/zicket-events.json | 956 +++++++++++++++++++++++ lib/starknet/commitment.ts | 124 +++ lib/starknet/config.ts | 81 ++ lib/starknet/metadata.ts | 64 ++ lib/starknet/server.ts | 140 ++++ lib/starknet/use-ticket-purchase.ts | 180 +++++ lib/starknet/zicket.ts | 258 ++++++ lib/types.ts | 23 +- package.json | 14 +- pnpm-lock.yaml | 165 +++- scripts/seed.ts | 22 +- scripts/starknet/common.ts | 86 ++ scripts/starknet/deploy.ts | 179 +++++ scripts/starknet/e2e.ts | 250 ++++++ scripts/starknet/env.ts | 58 ++ scripts/starknet/extract-abi.ts | 43 + scripts/starknet/flow-e2e.ts | 273 +++++++ src/db/schema.ts | 63 +- src/index.ts | 18 +- tsconfig.json | 2 +- 47 files changed, 6485 insertions(+), 23 deletions(-) create mode 100644 app/api/chain/config/route.ts create mode 100644 app/api/chain/events/[id]/route.ts create mode 100644 app/api/chain/purchases/route.ts create mode 100644 components/web/connect-wallet-button.tsx create mode 100644 components/web/wallet-provider.tsx create mode 100644 contracts/Scarb.lock create mode 100644 contracts/Scarb.toml create mode 100644 contracts/src/interfaces.cairo create mode 100644 contracts/src/lib.cairo create mode 100644 contracts/src/mock_erc20.cairo create mode 100644 contracts/src/types.cairo create mode 100644 contracts/src/zicket_events.cairo create mode 100644 contracts/tests/test_zicket.cairo create mode 100644 deployments/devnet.json create mode 100644 drizzle/0000_wandering_raider.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 lib/starknet/abis/mock-erc20.json create mode 100644 lib/starknet/abis/zicket-events.json create mode 100644 lib/starknet/commitment.ts create mode 100644 lib/starknet/config.ts create mode 100644 lib/starknet/metadata.ts create mode 100644 lib/starknet/server.ts create mode 100644 lib/starknet/use-ticket-purchase.ts create mode 100644 lib/starknet/zicket.ts create mode 100644 scripts/starknet/common.ts create mode 100644 scripts/starknet/deploy.ts create mode 100644 scripts/starknet/e2e.ts create mode 100644 scripts/starknet/env.ts create mode 100644 scripts/starknet/extract-abi.ts create mode 100644 scripts/starknet/flow-e2e.ts 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..252a58a 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,93 @@ Seed your Neon DB from `lib/mock_data.ts`: - `pnpm db:seed` +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. + +### 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 | + +On `devnet` the wallet menu also offers a predeployed burner account, so the +purchase flow can be exercised without a browser extension. + +### 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. + + ## Learn More To learn more about Next.js, take a look at the following resources: 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..ad41fff --- /dev/null +++ b/app/api/chain/events/[id]/route.ts @@ -0,0 +1,148 @@ +import { NextResponse } from "next/server"; + +import { CallData, cairo } from "starknet"; + +import { getTicketById, markTicketPublished } from "@/lib/db/queries"; +import { 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 (!ticket.onchain_event_id) { + 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 (ticket.onchain_event_id) { + 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, + 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..1840a32 --- /dev/null +++ b/app/api/chain/purchases/route.ts @@ -0,0 +1,116 @@ +import { NextResponse } from "next/server"; + +import { + getPurchasesForTicket, + getTicketById, + incrementAttendeeCount, + recordPurchase, +} from "@/lib/db/queries"; +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 (!ticket.onchain_event_id) { + 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/navbar.tsx b/components/web/navbar.tsx index 043a5a0..f0a20de 100644 --- a/components/web/navbar.tsx +++ b/components/web/navbar.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import Image from "next/image"; import { Button } from "@/components/ui/button"; import { SwitchToggle } from "@/components/ui/switch-toggle"; +import { ConnectWalletButton } from "@/components/web/connect-wallet-button"; import Link from "next/link"; export function Navbar() { @@ -54,6 +55,7 @@ export function Navbar() { onChange={setIsAnonymous} label="Anonymous Browsing" /> + @@ -169,6 +171,7 @@ export function Navbar() { + diff --git a/components/web/ticket-purchase-card.tsx b/components/web/ticket-purchase-card.tsx index 5780d1c..6b4da58 100644 --- a/components/web/ticket-purchase-card.tsx +++ b/components/web/ticket-purchase-card.tsx @@ -12,6 +12,9 @@ import { SelectValue, } from "@/components/ui/select"; import type { Ticket } from "@/lib/types"; +import { useWallet } from "@/components/web/wallet-provider"; +import { explorerTxUrl } from "@/lib/starknet/config"; +import { useTicketPurchase } from "@/lib/starknet/use-ticket-purchase"; import Image from "next/image"; type TicketType = { @@ -41,6 +44,30 @@ export function TicketPurchaseCard({ const [selectedType, setSelectedType] = useState(types[0]?.id ?? "standard"); const [email, setEmail] = useState(""); + const [showConnectHint, setShowConnectHint] = useState(false); + + 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"; @@ -110,9 +137,57 @@ export function TicketPurchaseCard({ Secure & Instant Payment - + + {showConnectHint && !isConnected && ( +

+ Connect a Starknet wallet from the header to continue. +

+ )} + + {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..f5b7177 --- /dev/null +++ b/contracts/src/interfaces.cairo @@ -0,0 +1,78 @@ +//! 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..3f60475 --- /dev/null +++ b/contracts/src/mock_erc20.cairo @@ -0,0 +1,172 @@ +//! 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..e75d6b9 --- /dev/null +++ b/contracts/src/zicket_events.cairo @@ -0,0 +1,758 @@ +//! # 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..64e40d5 --- /dev/null +++ b/contracts/tests/test_zicket.cairo @@ -0,0 +1,628 @@ +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/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/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/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..1bffb2b --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1785290542680, + "tag": "0000_wandering_raider", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 7f40c7e..407dd1d 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,10 @@ 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, + metadata_hash: row.metadataHash, + organizer_address: row.organizerAddress, + publish_tx_hash: row.publishTxHash, }; } @@ -119,3 +127,138 @@ export async function getRelatedNews(excludeId: string, limit = 3): Promise { + const db = getDb(); + const rows = await db + .update(ticketsTable) + .set({ + onchainEventId: params.onchainEventId, + 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..2c29a71 --- /dev/null +++ b/lib/starknet/config.ts @@ -0,0 +1,81 @@ +/** + * 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(); +} 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..b7098f9 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -11,7 +11,28 @@ 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, + /** 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/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..dde94dc --- /dev/null +++ b/scripts/starknet/deploy.ts @@ -0,0 +1,179 @@ +/** + * 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); + +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") { + throw new Error( + `PAYMENT_TOKEN_ADDRESS must be set for "${NETWORK}" — ` + + "the mock token is only deployed on devnet.", + ); + } 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..64b2acb --- /dev/null +++ b/scripts/starknet/e2e.ts @@ -0,0 +1,250 @@ +/** + * 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 { assertNodeReachable, getDeployer, getProvider, NETWORK, ROOT } from "./common"; + +const DEVNET_BUYERS = [ + { + address: "0x78662e7352d062084b0010068b99288486c2d8b914f6e2a55ce945f8792c8b1", + privateKey: "0xe1406455b7d66b1690803be066cbe5e", + }, + { + address: "0x49dfb8ce986e21d354ac93ea65e6a11f639c1934ea253e5ff14ca62eca0f38e", + privateKey: "0xa20a02f0ac53692d144b20cb371a60d7", + }, +]; + +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 = DEVNET_BUYERS.map( + (b) => new Account({ provider, address: b.address, signer: b.privateKey }), + ); + + 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..55b43c3 --- /dev/null +++ b/scripts/starknet/flow-e2e.ts @@ -0,0 +1,273 @@ +/** + * 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"; +// Importing common also loads .env.local / .env into process.env. +import { RPC_URL } from "./common"; + +const APP_URL = process.env.APP_URL ?? "http://localhost:3100"; + +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 ?? []; +} + +/** 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 devnetAccounts(rpcUrl)).filter( + (w) => BigInt(w.address) !== BigInt(adminAddress), + ); + if (wallets.length === 0) throw new Error("No devnet 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..486f5cf 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,55 @@ 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"), + /** 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, From e85643e36a5486e6222a1efea1bec886f86664af Mon Sep 17 00:00:00 2001 From: wheval Date: Wed, 29 Jul 2026 09:04:56 +0100 Subject: [PATCH 2/5] Deploy ticketing contracts to Starknet Sepolia Both contracts are live on Sepolia and the app runs against them. Deploying the mock ERC-20 outside devnet is now opt-in via ZICKET_DEPLOY_MOCK_TOKEN so a real payment token can't be swapped out by accident; on Sepolia the mock is intentional, since its permissionless mint doubles as a faucet. The e2e harnesses hardcoded devnet's predeployed accounts, so neither could run anywhere else. scripts/starknet/buyers.ts provisions buyers per network: predeployed on devnet, otherwise generated, funded from the deployer and counterfactually deployed, with keys cached in .env.local. Scope on-chain linkage to a deployment. Event ids restart at 1 for every ZicketEvents deployment, so tickets.onchain_event_id alone was ambiguous: after repointing at Sepolia, listings still carrying devnet ids resolved to zero-filled events, publish short-circuited as "already published", and buys reverted. Tickets now record the contract address that issued the id, and a mismatch reads as unpublished. 20/20 contract assertions and 32/32 full-stack assertions pass against live Sepolia. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 38 ++- app/api/chain/events/[id]/route.ts | 12 +- app/api/chain/purchases/route.ts | 3 +- deployments/sepolia.json | 22 ++ drizzle/0001_stormy_galactus.sql | 1 + drizzle/meta/0001_snapshot.json | 423 +++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/db/queries.ts | 3 + lib/starknet/config.ts | 24 ++ lib/types.ts | 2 + scripts/starknet/buyers.ts | 155 +++++++++++ scripts/starknet/deploy.ts | 10 +- scripts/starknet/e2e.ts | 15 +- scripts/starknet/flow-e2e.ts | 33 ++- src/db/schema.ts | 7 + 15 files changed, 729 insertions(+), 26 deletions(-) create mode 100644 deployments/sepolia.json create mode 100644 drizzle/0001_stormy_galactus.sql create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 scripts/starknet/buyers.ts diff --git a/README.md b/README.md index 252a58a..fa15357 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,41 @@ pnpm chain:flow # 32 assertions through the running Next.js app `NEXT_PUBLIC_PAYMENT_TOKEN_ADDRESS` and the relayer credentials into `.env.local`. Restarting devnet resets chain state, so re-run the deploy. +### Sepolia + +Live on Starknet Sepolia — see [`deployments/sepolia.json`](deployments/sepolia.json). + +| Contract | Address | +| --- | --- | +| `ZicketEvents` | `0x7b318fc47e158025e02cb41c6b6ae74d214e1d5b8c58241b09f8b0b452b8cbb` | +| `ZUSD` payment token (18dp) | `0x785ab439b53aa6452deaa13a8bbaca43004284e6c3bebadf07f8ebb73333f15` | + +```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 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 +``` + +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 | @@ -101,7 +136,8 @@ pnpm chain:flow # 32 assertions through the running Next.js app | `STARKNET_ADMIN_ADDRESS` / `STARKNET_ADMIN_PRIVATE_KEY` | Server relayer that publishes listings | On `devnet` the wallet menu also offers a predeployed burner account, so the -purchase flow can be exercised without a browser extension. +purchase flow can be exercised without a browser extension. On Sepolia and +mainnet a real wallet (Argent X or Braavos) is required. ### API diff --git a/app/api/chain/events/[id]/route.ts b/app/api/chain/events/[id]/route.ts index ad41fff..e30e6a4 100644 --- a/app/api/chain/events/[id]/route.ts +++ b/app/api/chain/events/[id]/route.ts @@ -3,7 +3,11 @@ import { NextResponse } from "next/server"; import { CallData, cairo } from "starknet"; import { getTicketById, markTicketPublished } from "@/lib/db/queries"; -import { usdToTokenUnits, ZICKET_CONTRACT_ADDRESS } from "@/lib/starknet/config"; +import { + isPublishedToCurrentDeployment, + usdToTokenUnits, + ZICKET_CONTRACT_ADDRESS, +} from "@/lib/starknet/config"; import { metadataHashForTicket } from "@/lib/starknet/metadata"; import { eventIdFromReceipt, @@ -24,7 +28,7 @@ export async function GET(_req: Request, { params }: Props) { const ticket = await getTicketById(id); if (!ticket) return NextResponse.json({ error: "Not found" }, { status: 404 }); - if (!ticket.onchain_event_id) { + if (!isPublishedToCurrentDeployment(ticket)) { return NextResponse.json({ published: false, ticketId: id }); } @@ -79,14 +83,13 @@ export async function POST(_req: Request, { params }: Props) { const ticket = await getTicketById(id); if (!ticket) return NextResponse.json({ error: "Not found" }, { status: 404 }); - if (ticket.onchain_event_id) { + 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; @@ -126,6 +129,7 @@ export async function POST(_req: Request, { params }: Props) { await markTicketPublished({ ticketId: id, onchainEventId, + onchainContractAddress: ZICKET_CONTRACT_ADDRESS, metadataHash, organizerAddress: admin.address, publishTxHash: transaction_hash, diff --git a/app/api/chain/purchases/route.ts b/app/api/chain/purchases/route.ts index 1840a32..49bc67f 100644 --- a/app/api/chain/purchases/route.ts +++ b/app/api/chain/purchases/route.ts @@ -6,6 +6,7 @@ import { 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. */ @@ -59,7 +60,7 @@ export async function POST(req: Request) { if (!ticket) { return NextResponse.json({ error: "Unknown ticket" }, { status: 404 }); } - if (!ticket.onchain_event_id) { + if (!isPublishedToCurrentDeployment(ticket)) { return NextResponse.json( { error: "This listing has not been published on-chain yet" }, { status: 409 }, 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/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/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 index 1bffb2b..6f9e323 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "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 407dd1d..e700ebc 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -29,6 +29,7 @@ function mapRowToTicket(row: typeof ticketsTable.$inferSelect): Ticket { 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, @@ -136,6 +137,7 @@ export async function getRelatedNews(excludeId: string, limit = 3): Promise(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/types.ts b/lib/types.ts index b7098f9..4571974 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -14,6 +14,8 @@ export interface Ticket { 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, 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/deploy.ts b/scripts/starknet/deploy.ts index dde94dc..e6e0657 100644 --- a/scripts/starknet/deploy.ts +++ b/scripts/starknet/deploy.ts @@ -25,6 +25,11 @@ 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; @@ -99,10 +104,11 @@ async function main() { if (paymentToken) { console.log(` using existing payment token ${paymentToken}`); - } else if (NETWORK !== "devnet") { + } 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.", + "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"); diff --git a/scripts/starknet/e2e.ts b/scripts/starknet/e2e.ts index 64b2acb..87287ed 100644 --- a/scripts/starknet/e2e.ts +++ b/scripts/starknet/e2e.ts @@ -14,18 +14,9 @@ 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 DEVNET_BUYERS = [ - { - address: "0x78662e7352d062084b0010068b99288486c2d8b914f6e2a55ce945f8792c8b1", - privateKey: "0xe1406455b7d66b1690803be066cbe5e", - }, - { - address: "0x49dfb8ce986e21d354ac93ea65e6a11f639c1934ea253e5ff14ca62eca0f38e", - privateKey: "0xa20a02f0ac53692d144b20cb371a60d7", - }, -]; const PRICE = 5n * 10n ** 18n; const FUNDING = 1000n * 10n ** 18n; @@ -95,9 +86,7 @@ async function main() { // ── 1. Fund buyers ───────────────────────────────────────────────────────── console.log("\n[1] fund buyers"); - const buyers = DEVNET_BUYERS.map( - (b) => new Account({ provider, address: b.address, signer: b.privateKey }), - ); + const buyers = await provisionBuyers(provider, 2); for (const buyer of buyers) { await send(admin, { diff --git a/scripts/starknet/flow-e2e.ts b/scripts/starknet/flow-e2e.ts index 55b43c3..de72834 100644 --- a/scripts/starknet/flow-e2e.ts +++ b/scripts/starknet/flow-e2e.ts @@ -11,11 +11,19 @@ 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 { RPC_URL } from "./common"; +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; @@ -58,6 +66,23 @@ async function devnetAccounts(rpcUrl: string): Promise { 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, @@ -89,10 +114,8 @@ async function main() { // 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 devnetAccounts(rpcUrl)).filter( - (w) => BigInt(w.address) !== BigInt(adminAddress), - ); - if (wallets.length === 0) throw new Error("No devnet accounts available"); + 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}`); diff --git a/src/db/schema.ts b/src/db/schema.ts index 486f5cf..4a84513 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -51,6 +51,13 @@ export const tickets = pgTable("tickets", { // 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 }), From 520885e80295791f63d4739b48e3edde9f0cbb89 Mon Sep 17 00:00:00 2001 From: wheval Date: Wed, 29 Jul 2026 09:21:54 +0100 Subject: [PATCH 3/5] Document the Sepolia deployment and move wallet connect to checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the live contract addresses, class hashes, deployer and RPC in the README with explorer links, and note the JSON-RPC 0.9.0 requirement that constrains which public endpoints work. Replace the create-next-app boilerplate header and footer, which described neither the project nor how it is deployed, with a summary of what Zicket is and a table of the scripts. Drop the Connect Wallet button from the navbar. Connecting is only meaningful at checkout, so the purchase button now renders as Connect Wallet until a wallet is attached — previously the card just told the user to find the button in the header. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 102 +++++++++++++++--------- components/web/navbar.tsx | 3 - components/web/ticket-purchase-card.tsx | 35 +++----- 3 files changed, 79 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index fa15357..84c6e5c 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,32 @@ -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 +Privacy-first event ticketing. **Host Freely. Attend Silently.** + +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. -First, run the development server: +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) @@ -89,14 +97,36 @@ pnpm chain:flow # 32 assertions through the running Next.js app `NEXT_PUBLIC_PAYMENT_TOKEN_ADDRESS` and the relayer credentials into `.env.local`. Restarting devnet resets chain state, so re-run the deploy. -### Sepolia +### Sepolia — live deployment -Live on Starknet Sepolia — see [`deployments/sepolia.json`](deployments/sepolia.json). +Deployed 2026-07-29. Canonical record: [`deployments/sepolia.json`](deployments/sepolia.json). -| Contract | Address | +| 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` | `0x7b318fc47e158025e02cb41c6b6ae74d214e1d5b8c58241b09f8b0b452b8cbb` | -| `ZUSD` payment token (18dp) | `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 \ @@ -105,8 +135,8 @@ 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 token cannot be replaced by accident. On Sepolia the mock is -deliberate — its `mint` is permissionless, so it doubles as a faucet and any +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 @@ -114,6 +144,9 @@ 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 @@ -135,9 +168,10 @@ unpublished rather than resolving to some unrelated event. | `NEXT_PUBLIC_TOKEN_USD_PRICE` | USD → token conversion for listed prices | | `STARKNET_ADMIN_ADDRESS` / `STARKNET_ADMIN_PRIVATE_KEY` | Server relayer that publishes listings | -On `devnet` the wallet menu also offers a predeployed burner account, so the -purchase flow can be exercised without a browser extension. On Sepolia and -mainnet a real wallet (Argent X or Braavos) is required. +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 @@ -171,18 +205,14 @@ docker run -d --name zicket-neon-proxy -p 4444:4444 \ Then set `NEON_HTTP_ENDPOINT=http://localhost:4444/sql` alongside `DATABASE_URL`. Against a real Neon database, leave `NEON_HTTP_ENDPOINT` unset. +## Scripts -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [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. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -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. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +| 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 | diff --git a/components/web/navbar.tsx b/components/web/navbar.tsx index f0a20de..043a5a0 100644 --- a/components/web/navbar.tsx +++ b/components/web/navbar.tsx @@ -4,7 +4,6 @@ import { useState } from "react"; import Image from "next/image"; import { Button } from "@/components/ui/button"; import { SwitchToggle } from "@/components/ui/switch-toggle"; -import { ConnectWalletButton } from "@/components/web/connect-wallet-button"; import Link from "next/link"; export function Navbar() { @@ -55,7 +54,6 @@ export function Navbar() { onChange={setIsAnonymous} label="Anonymous Browsing" /> - @@ -171,7 +169,6 @@ export function Navbar() { - diff --git a/components/web/ticket-purchase-card.tsx b/components/web/ticket-purchase-card.tsx index 6b4da58..3516e44 100644 --- a/components/web/ticket-purchase-card.tsx +++ b/components/web/ticket-purchase-card.tsx @@ -13,6 +13,7 @@ import { } 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"; @@ -44,7 +45,6 @@ export function TicketPurchaseCard({ const [selectedType, setSelectedType] = useState(types[0]?.id ?? "standard"); const [email, setEmail] = useState(""); - const [showConnectHint, setShowConnectHint] = useState(false); const { status } = useWallet(); const { purchase, stage, error, result, isBusy } = useTicketPurchase(ticket); @@ -137,27 +137,18 @@ export function TicketPurchaseCard({ Secure & Instant Payment - - - {showConnectHint && !isConnected && ( -

- Connect a Starknet wallet from the header to continue. -

+ {isConnected ? ( + + ) : ( + )} {stage === "error" && error && ( From b788e3dd6c743510b7efaa6bcc3f6edd0e65ffd5 Mon Sep 17 00:00:00 2001 From: wheval Date: Wed, 29 Jul 2026 09:34:06 +0100 Subject: [PATCH 4/5] Add CI Three jobs on every PR: the Cairo contracts (fmt, build, 36 tests), the app (lint, typecheck, build), and a devnet deploy running the 20 contract assertions. None of this was checked automatically before. The build job needs a database, since / and /explore are prerendered from it, so it brings up Postgres behind the same Neon HTTP proxy the README documents for local development. Migrations run through psql: drizzle-kit picks the Neon WebSocket driver, which that proxy does not serve. scarb fmt --check required reformatting. The section banner comments were long enough to exceed the line limit, so the formatter wrapped them onto a second line and split the rule from its heading; shortening them keeps the banners intact and the formatting canonical. Verified by reproducing each job locally, including a build against an empty database and a deploy plus e2e against a fresh devnet with no .env.local. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 165 ++++++++++++++++++++++++++++++ README.md | 19 ++++ contracts/src/interfaces.cairo | 21 ++-- contracts/src/mock_erc20.cairo | 4 +- contracts/src/zicket_events.cairo | 47 +++------ contracts/tests/test_zicket.cairo | 4 +- 6 files changed, 206 insertions(+), 54 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2c90ed4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,165 @@ +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 }} + + - 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 + run: | + for _ in $(seq 1 30); do + if curl -sf -o /dev/null "http://localhost:4444/sql" \ + -X POST -H 'content-type: application/json' \ + -d '{"query":"select 1","params":[]}'; then + echo "proxy ready"; exit 0 + fi + sleep 2 + done + echo "proxy did not come up"; 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 }} + + - 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/README.md b/README.md index 84c6e5c..8b40bc9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Zicket +[![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.** Browse and buy tickets without handing over an identity: alongside ordinary @@ -216,3 +218,20 @@ Then set `NEON_HTTP_ENDPOINT=http://localhost:4444/sql` alongside | `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 | + +## CI + +[`.github/workflows/ci.yml`](.github/workflows/ci.yml) runs three jobs on every PR: + +| 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 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. + +`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/contracts/src/interfaces.cairo b/contracts/src/interfaces.cairo index f5b7177..d6f88d2 100644 --- a/contracts/src/interfaces.cairo +++ b/contracts/src/interfaces.cairo @@ -7,15 +7,10 @@ use zicket::types::{EventData, TicketData}; #[starknet::interface] pub trait IERC20 { fn balance_of(self: @TContractState, account: ContractAddress) -> u256; - fn allowance( - self: @TContractState, owner: ContractAddress, spender: 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, + ref self: TContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256, ) -> bool; fn approve(ref self: TContractState, spender: ContractAddress, amount: u256) -> bool; } @@ -23,7 +18,7 @@ pub trait IERC20 { /// Core ticketing interface. #[starknet::interface] pub trait IZicketEvents { - // ── Organizer ──────────────────────────────────────────────────────────── + // ── Organizer ── fn create_event( ref self: TContractState, metadata_hash: felt252, @@ -36,11 +31,9 @@ pub trait IZicketEvents { fn cancel_event(ref self: TContractState, event_id: u64); fn withdraw(ref self: TContractState, event_id: u64) -> u256; - // ── Attendee ───────────────────────────────────────────────────────────── + // ── 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 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( @@ -55,7 +48,7 @@ pub trait IZicketEvents { recipient: ContractAddress, ) -> u256; - // ── Views ──────────────────────────────────────────────────────────────── + // ── 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; @@ -67,7 +60,7 @@ pub trait IZicketEvents { fn compute_commitment(self: @TContractState, secret: felt252, nullifier: felt252) -> felt252; fn compute_nullifier_hash(self: @TContractState, nullifier: felt252) -> felt252; - // ── Admin ──────────────────────────────────────────────────────────────── + // ── Admin ── fn payment_token(self: @TContractState) -> ContractAddress; fn platform_fee_bps(self: @TContractState) -> u16; fn fee_recipient(self: @TContractState) -> ContractAddress; diff --git a/contracts/src/mock_erc20.cairo b/contracts/src/mock_erc20.cairo index 3f60475..4d34132 100644 --- a/contracts/src/mock_erc20.cairo +++ b/contracts/src/mock_erc20.cairo @@ -149,9 +149,7 @@ pub mod MockERC20 { self.balances.entry(recipient).write(self.balances.entry(recipient).read() + amount); self .emit( - Event::Transfer( - Transfer { from: Zero::zero(), to: recipient, value: amount }, - ), + Event::Transfer(Transfer { from: Zero::zero(), to: recipient, value: amount }), ); } diff --git a/contracts/src/zicket_events.cairo b/contracts/src/zicket_events.cairo index e75d6b9..e335034 100644 --- a/contracts/src/zicket_events.cairo +++ b/contracts/src/zicket_events.cairo @@ -24,9 +24,7 @@ pub mod ZicketEvents { 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 starknet::{ContractAddress, get_block_timestamp, get_caller_address, get_contract_address}; use zicket::interfaces::{IERC20Dispatcher, IERC20DispatcherTrait, IZicketEvents}; use zicket::types::{EventData, TicketData, TicketMode}; @@ -222,7 +220,7 @@ pub mod ZicketEvents { #[abi(embed_v0)] pub impl ZicketEventsImpl of IZicketEvents { - // ── Organizer ──────────────────────────────────────────────────────── + // ── Organizer ── fn create_event( ref self: ContractState, metadata_hash: felt252, @@ -314,9 +312,7 @@ pub mod ZicketEvents { 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, - ); + assert(token.transfer(self.fee_recipient.read(), fee), Errors::PAYMENT_FAILED); } if payout > 0 { assert(token.transfer(caller, payout), Errors::PAYMENT_FAILED); @@ -327,10 +323,7 @@ pub mod ZicketEvents { .emit( Event::Withdrawn( Withdrawn { - event_id, - organizer: caller, - organizer_amount: payout, - fee_amount: fee, + event_id, organizer: caller, organizer_amount: payout, fee_amount: fee, }, ), ); @@ -338,7 +331,7 @@ pub mod ZicketEvents { payout } - // ── Attendee ───────────────────────────────────────────────────────── + // ── Attendee ── fn buy_ticket(ref self: ContractState, event_id: u64) -> u64 { let mut event = self._load_event(event_id); let buyer = get_caller_address(); @@ -463,12 +456,7 @@ pub mod ZicketEvents { 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 }, - ), - ); + self.emit(Event::TicketTransferred(TicketTransferred { ticket_id, from: caller, to })); } fn check_in(ref self: ContractState, ticket_id: u64) { @@ -477,9 +465,7 @@ pub mod ZicketEvents { 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(ticket.owner == caller || event.organizer == caller, Errors::NOT_TICKET_OWNER); assert(!event.cancelled, Errors::EVENT_CANCELLED); assert(!ticket.checked_in, Errors::ALREADY_CHECKED_IN); @@ -573,7 +559,7 @@ pub mod ZicketEvents { self._settle_refund(ticket_id, ref ticket, recipient) } - // ── Views ──────────────────────────────────────────────────────────── + // ── Views ── fn get_event(self: @ContractState, event_id: u64) -> EventData { self.events.entry(event_id).read() } @@ -586,15 +572,11 @@ pub mod ZicketEvents { self.ticket_by_attendee.entry(event_id).entry(attendee).read() } - fn ticket_of_commitment( - self: @ContractState, event_id: u64, commitment: felt252, - ) -> u64 { + 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 { + fn is_nullifier_used(self: @ContractState, event_id: u64, nullifier_hash: felt252) -> bool { self.nullifier_used.entry(event_id).entry(nullifier_hash).read() } @@ -625,7 +607,7 @@ pub mod ZicketEvents { self._nullifier_hash(nullifier) } - // ── Admin ──────────────────────────────────────────────────────────── + // ── Admin ── fn payment_token(self: @ContractState) -> ContractAddress { self.payment_token.read() } @@ -670,9 +652,7 @@ pub mod ZicketEvents { self.owner.write(new_owner); self .emit( - Event::OwnershipTransferred( - OwnershipTransferred { previous_owner, new_owner }, - ), + Event::OwnershipTransferred(OwnershipTransferred { previous_owner, new_owner }), ); } } @@ -710,8 +690,7 @@ pub mod ZicketEvents { } let token = IERC20Dispatcher { contract_address: self.payment_token.read() }; assert( - token.transfer_from(payer, get_contract_address(), amount), - Errors::PAYMENT_FAILED, + token.transfer_from(payer, get_contract_address(), amount), Errors::PAYMENT_FAILED, ); } diff --git a/contracts/tests/test_zicket.cairo b/contracts/tests/test_zicket.cairo index 64e40d5..8a899ed 100644 --- a/contracts/tests/test_zicket.cairo +++ b/contracts/tests/test_zicket.cairo @@ -87,9 +87,7 @@ fn fund_approval(ctx: Ctx, who: ContractAddress) { 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); + let id = ctx.zicket.create_event('META', PRICE, 100, START_TIME, END_TIME, anonymous_allowed); stop_cheat_caller_address(ctx.zicket_address); id } From 321e2b3f671405aef24fa33cba2a2cf2dbf06181 Mon Sep 17 00:00:00 2001 From: wheval Date: Wed, 29 Jul 2026 09:39:05 +0100 Subject: [PATCH 5/5] Fix the proxy readiness check in CI A bare POST to the Neon proxy's /sql is rejected with 400 without its connection headers, so curl -f read a healthy proxy as a failure and the job timed out waiting on a service that was already up. Wait on the port instead. Also point setup-scarb at contracts/Scarb.lock so it can cache dependencies; the workspace is not at the repository root. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c90ed4..b438c14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,7 @@ jobs: - uses: software-mansion/setup-scarb@v1 with: scarb-version: ${{ env.SCARB_VERSION }} + scarb-lock: contracts/Scarb.lock - uses: foundry-rs/setup-snfoundry@v6 with: @@ -105,16 +106,16 @@ jobs: 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 curl -sf -o /dev/null "http://localhost:4444/sql" \ - -X POST -H 'content-type: application/json' \ - -d '{"query":"select 1","params":[]}'; then + 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"; exit 1 + echo "proxy did not come up"; docker ps -a; exit 1 - name: Build run: pnpm build @@ -128,6 +129,7 @@ jobs: - uses: software-mansion/setup-scarb@v1 with: scarb-version: ${{ env.SCARB_VERSION }} + scarb-lock: contracts/Scarb.lock - uses: pnpm/action-setup@v6 with: