Spec: Pack Opening × Market Integration (v2)
Status: Proposed — pending approval, then converts to an implementation plan Date: 2026-07-07 Deciders: CDO, Eng leads (native, d-sports-api, d-sports-backend) Supersedes: v1 (2026-07-07) Inputs: CDO proposal (PACK_OPENING_MARKET_INTEGRATION_PLAN.md); full audit of d-sports-engage-native, d-sports-api, d-sports-backend, Pack-opening, D-Sports-Local-Docs; external compliance/RNG research; ADR-0001 (d-sports-backend/docs/proposals/onchain-gacha-architecture.md).
0. Decisions locked in this revision
1. Naming (unchanged, for the record)
d-sports-api= TypeScript / Next.js 16 / Prisma 6 / Postgres — live prod, and it hosts/admintoday (route group inside the app).d-sports-backend= Rust / Axum / Diesel — fallback in progress (Strangler-Fig). Pack/marketplace/collectibles handlers areTODOstubs; it proxies/api/*tod-sports-api.d-sports-engage-native= Expo/RN app. Prod → TS backend; Rust only for local dev parity.
2. What already exists (reuse, don’t rebuild)
d-sports-api already ships most of the mechanics the CDO plan treats as new:
- Schema:
Collectible(rarity,supply,minted, teamId),Pack(price, currency,quantity,sold,statusdraft/live,maxReveal,animationId,contractAddress),PackCollectible(quantity+weight= the odds mechanism),PackPurchase(status, paymentMethod,openedAt),MintedNFT,UserCollectible,Rarityenum =COMMON, RARE, EPIC, LEGENDARY, MYTHIC(5 tiers — plan spec’d 4). - Odds engine:
lib/pack-odds.ts(computePackOdds,getPackOpenability),lib/weighted-random.ts(weightedRandomSelectMultiple), tested (server/__tests__/pack-odds.test.ts). - Server logic:
server/pack-actions.ts(989 lines — pack CRUD, odds, openability),server/minting-actions.ts(951 lines — sold-out check, weighted draw ofmaxRevealcards,MintedNFT+UserCollectiblecreation,Collectible.mintedincrement). - Admin CRUD (web):
app/(dashboard)/admin/packs/,components/Admin/tabs/ManagePacksTab.tsx,CreateCollectibleTab.tsx,pack-management.tsx,pack-testing.tsx. Role-gated byUserRole+canManageTeam/canAccessTeam, enforced server-side on every/api/admin/*route. - GLB pipeline (works): seed script → Supabase
pack-assets/teams/{id}/animations/→GET /api/admin/team-animations(list) → dropdown select inManagePacksTab→ stored asanimationId/modelUrl→ rendered by nativeWebGlbViewer(components/shop/WebGlbViewer.tsx/.web.tsx). Gap: no interactive GLB upload endpoint/UI found in any committed branch (see §7.3). - Shop/checkout (= the plan’s “Market”):
app/(tabs)/shop.tsx,PackDetailModal.tsx,CartModal.tsx, checkout vialib/api/checkout-api.ts→ crypto + DSports Cash. RevenueCat for IAP. - Pack opening (2D): native
components/wallet/PackOpeningModal.tsx,components/shop/PackOpeningView.tsx—Animated+ hand-rolled confetti; rarity colors inlib/rarity-utils.ts(all 5 tiers). No video, no white-flash, no 3D orbit yet. - Compliance groundwork:
d-sports-engage-native/docs/compliance/odds-disclosure-copy-guidelines.md,pack-odds-client-integration.md.
d-sports-api. Genuinely new: Binder, in-app admin, interactive GLB upload, the richer opening animation, and the Option-B finite-mint mode.
3. Corrections to the CDO plan that still stand
PackOpening.tsx/getRarityColors()don’t exist.Pack-opening/is a ~550-line vanilla JS/CSS prototype (rawrequestAnimationFrameorbit, Web Animations API particles, 2 hardcoded rarities). Treat as visual spec; the port target is the nativeAnimatedcomponents, not this code (React Native has no DOM to port the prototype’squerySelectorlogic into).- Binder is not an existing feature. Only a static design HTML exists (
D-Sports-Local-Docs/TDD/_binder-preview/TDD-Binder-Preview.html). Nearest live analog (Wallet, grouped by rarity) is not a binder. - “Market” is the existing Shop. Repositioning is product/UX, not new plumbing.
- White-flash timing is per-video, not a constant. Prototype’s
WHITEOUT = 7.733s/DUR = 8.367sare tuned to one asset. Must become per-pack metadata (§4.1). POST /admin/packsreturningmint_instancesonly makes sense under Option B (§4.2).
4. Target design
4.1 Schema changes (d-sports-api, additive)
Pack — add:
mintMode = FINITE:
ODDS_ONLY(default): current behavior —PackCollectible.weightonly, nothing decrements, no sold-out per card.FINITE: on publish, generateMintInstancerows per the admin’s copies-per-tier allocation. A pull marks an instancedrawninside the open transaction; pack becomes unopenable when exhausted. Reconciles with the existing unusedCollectible.supply/.mintedfields.- Validation (plan §3): target count must divide across the pool without remainder → surfaced in admin UI as the “adjust to 300 or 304” warning.
- Quantity-bucket model (revised after plan review):
UserCollectibleis unique per(userId, collectibleId)with aquantitycounter the mint flow increments — soBinderCardcannot be one-row-per-copy. It’s a bucket keyed(binderId, collectibleId). Invariant (app-enforced inbinder-actions.ts):sum(BinderCard.quantity across a user's binders, per collectible) <= UserCollectible.quantity. “Loose”/unplaced copies = owned − placed. - “Open Later” = purchase completes with a chosen
PackPurchase.targetBinderId(new nullable field) butopenedAt = null; opening later lands into that binder. - Transfer = quantity move between two
(binderId, collectibleId)buckets in one transaction. - PIN: hashed with salted PBKDF2 (
lib/binder-pin.ts— reuses thepbkdf2primitive already imported inserver/wallet-actions.ts, but salted; the wallet code’s own PIN hashing is unsalted SHA-256, which we deliberately do NOT copy). PIN gating is UX-level; ownership is the real boundary.
4.2 API (extend, don’t replace)
ActionResult<T> envelope.
4.3 In-app admin (D6) — client-only lift
Admin auth is Clerk-session + server-side role checks, not web-coupled — so a native admin surface reuses the same/api/admin/* endpoints with no new backend auth:
- New
app/(admin)/route group in native, gated by a porteduseAdminRole()(mirror web’sAdminProvider/useAdminContext). - Screens this phase: pack create/edit, card-pool multi-select, mint/odds config (incl. Option-B allocation UI), pack list. Rebuild
ManagePacksTab/CreateCollectibleTablogic as native screens (not DOM ports). - Out of scope this phase (stays on web, tracked for later migration): user management, revenue/FX, team-view, reports.
4.4 Pack opening animation (D3) — native
Extend the nativeAnimated components; use the prototype only as the visual target:
- Video via
expo-video, driven by per-packvideoDurationMs/whiteFlashMs(not constants). - Orbit/stack/swipe: extend existing gesture handling; scale ring by
packSize(3/5/7). - Rarity FX:
lib/rarity-utils.tsalready maps all 5 tiers’ colors — add per-tier particle/beam/haptic intensity. - 3D: reuse
WebGlbViewerfor post-open card detail only (matches the plan’s own assumption).
5. RNG & fairness (D5)
Now (this feature, TS): replaceMath.random() in lib/weighted-random.ts with a Node crypto CSPRNG over the weighted cumulative distribution. (components/Admin/pack-testing.tsx:224 deliberately stays on Math.random — it’s a client-side simulation that awards nothing and can’t import node:crypto; it’s also being promoted to a public “preview this pack” feature: shoppers can run a clearly-watermarked simulated opening drawn from the real disclosed odds before buying — an interactive odds-disclosure asset. The native shop’s PackDetailModal gets the same preview and drops its hardcoded drop rates for GET /api/packs/{id}/odds.) Add an append-only draw audit log: per open — userId, packPurchaseId, packId, PackCollectible weights snapshot + a hash of the odds table version, RNG output, awarded cards, timestamp. This is the pragmatic, store-defensible default (research verdict below). Low effort, high compliance value.
Later (already a proposed ADR — do not redesign): d-sports-backend/docs/proposals/onchain-gacha-architecture.md (ADR-0001, status Proposed) specifies the on-chain-fair path: a drand quicknet BLS beacon as VRF + commit-reveal, with the drawing-pool spec SHA-256 hashed and anchored on-chain, and pull-to-mint with finite supply caps — which aligns with the Option-B model in D2. No Rust code exists yet (no rand crate; hmac/sha2 are only used by the oracle webhook verifier). Treat ADR-0001 as the future track; the CSPRNG+audit-log work above is forward-compatible with it (same odds-table-hash concept).
Research verdict (2026): App/Play stores require pre-purchase odds disclosure, not a specific RNG architecture. Server-side CSPRNG weighted draw + versioned odds table + append-only log is the recommended default; commit-reveal is optional (trust-as-feature); on-chain VRF is overkill until the economy is on-chain-first. Sources: Apple loot-box odds disclosure, Google Play requirement, 2026 jurisdiction map.
Also required: verify GET /packs/{id}/odds output reaches the purchase UI pre-checkout (not just the reveal). PEGI moves to mandatory 16+ for paid random items from June 2026; Belgium bans paid loot boxes; SK/China mandate public probability pages — relevant if those markets are in scope.
6. Open questions needing your call
7. Phased plan (converts to tickets on approval)
Phase 0 — Foundations & compliance- CSPRNG swap + draw audit log (§5); confirm pre-purchase odds disclosure wiring; resolve Q1-Q4.
- Schema migration: Pack fields,
MintMode/PackSize,MintInstance,Binder/BinderCard.
- Native
app/(admin)/route group + role gate. - Pack create/edit, card-pool multi-select, odds config, Option-B allocation UI + divisibility validation.
upload-videoendpoint (+ duration/codec validation);upload-modelinteractive GLB endpoint (Q2).
Binder/BinderCardAPI (list/create/verify-pin/contents/transfer).- Native Binder screens: cover carousel, PIN entry, shelf-grouped interior.
- Binder-selection modal at checkout (open now / open later / create-inline).
- Port the prototype look into native opening: video + per-pack timing, 5-tier FX, haptics, packSize-scaled orbit.
POST /packs/{id}/openextended forbinderId+ FINITE-mode instance marking, atomic.
- Card transfer between binders (Q1 restrictions).
- Post-open binder redirect + new-card highlight/badge.
- Rust parity backlog (D1): file every new model/endpoint into
d-sports-backend/docs/parity/plans/PARITY_GAPS.md; ensure the legacy-compat shim (crates/engage/src/legacy_compat/handlers/packs.rs,commerce.rs) proxies the new/api/*routes so native never breaks at cutover. Port schema 1:1 from §4.1 whencrates/collectibles/marketplaceleave stub status.
