# SkateSpots Protocol This document defines the Nostr event kinds used by the SkateSpots application for managing skateboarding locations, shops, and related content. ## Profile Metadata Extensions (kind 0) In addition to the standard NIP-01 metadata fields (`name`, `about`, `picture`, `banner`, `website`, `nip05`, `lud16`, `bot`), SkateSpots profiles may carry a few custom keys inside the kind 0 content JSON. These are optional, ignored by other clients, and preserved on edit: | Field | Type | Description | |-------|------|-------------| | `noffer` | string | CLINK Offer code (`noffer1…`) — self-custodial Bitcoin tip code. | | `bitcoin_silent_payment` | string | Bitcoin Silent Payment address (BIP-352, `sp1…`) — a reusable, private on-chain address. | | `sponsors` | string | Freeform highlight of the skater's sponsors / supporters (brands, shops, people). | | `cordn` | string | The **Cordn coordinator** others use to reach you — private, end-to-end encrypted messaging. Holds a coordinator **pointer**: either an `nprofile` (coordinator pubkey + relay hints), Cordn's canonical `https://cordn.net/chat/coordinators?c=` add-link, or an `http(s)` endpoint URL. This is the discovery field: clients SHOULD offer a one-tap **add** when it parses as a pointer, and otherwise open it as a plain link. See *Private Crew Chat (Cordn / MLS)*. | | `cashu_escrow` | string | Cashu escrow pubkey (33-byte compressed secp256k1 hex, `02…`) used as the P2PK lock target for marketplace escrow. Derived from the Cashu wallet keyring. See `ESCROW.md`. | `lud16` (Lightning address) is standard NIP-01 but is also editable directly in the SkateSpots profile form for convenience. Example: ```json { "name": "Sk8rBoi", "lud16": "sk8rboi@getalby.com", "bitcoin_silent_payment": "sp1qq...long...", "sponsors": "Independent Trucks, Local Skate Co.", "cordn": "https://cordn.net/u/abc123", "noffer": "noffer1..." } ``` ## Skate Shops (kind 30017) Skate shops use the NIP-15 marketplace "stall" kind (30017) extended with skateboarding-specific tags. This enables interoperability with existing Nostr marketplace clients. ### Event Format ```json { "kind": 30017, "content": "", "tags": [ ["d", ""], ["name", ""], ["g", ""], ["location", ""], ["t", "skateshop"], ["phone", ""], ["email", ""], ["website", ""], ["instagram", ""], ["hours", ""], ["imeta", "url ", "m image/jpeg"], ["alt", "Skate shop: "] ] } ``` ### Required Tags | Tag | Description | |-----|-------------| | `d` | Unique identifier for the shop | | `name` | Shop name | | `g` | Geohash encoding the shop's coordinates | | `t` | Must include `skateshop` to identify as a skate shop | | `location` | Physical shop address | ### Optional Tags | Tag | Description | |-----|-------------| | `phone` | Contact phone number | | `email` | Contact email address | | `website` | Shop website URL | | `instagram` | Instagram handle | | `hours` | Hours of operation (freeform text) | | `noffer` | Optional CLINK Offer code (`noffer1…`) for self-custodial payments — see the CLINK Payments section | | `imeta` | Image metadata per NIP-92 | | `lat` | Latitude (decimal) | | `lon` | Longitude (decimal) | ### Shop Ownership Verification Shops can be verified using **NIP-05** domain verification. A shop is considered verified when: 1. The shop has a `website` tag with a domain (e.g., `supremenewyork.com`) 2. The shop owner's NIP-05 identifier matches the shop's domain (e.g., `owner@supremenewyork.com`) This proves the shop owner controls the domain, establishing ownership. ### Example ```json { "kind": 30017, "pubkey": "abc123...", "created_at": 1703001600, "content": "Los Angeles' premier skateboard shop since 1994. Full selection of decks, trucks, wheels, and streetwear.", "tags": [ ["d", "supreme-la"], ["name", "Supreme Los Angeles"], ["g", "9q5ctr"], ["location", "411 N Fairfax Ave, Los Angeles, CA 90036"], ["t", "skateshop"], ["phone", "+1-323-655-6205"], ["website", "https://www.supremenewyork.com"], ["instagram", "@supremenewyork"], ["hours", "Mon-Sat 11AM-7PM, Sun 12PM-6PM"], ["imeta", "url https://example.com/shop-front.jpg", "m image/jpeg"], ["lat", "34.0737"], ["lon", "-118.3614"], ["alt", "Skate shop: Supreme Los Angeles"] ], "id": "...", "sig": "..." } ``` ### Querying Shops ```json { "kinds": [30017], "#t": ["skateshop"] } ``` ## Skate Spot (kind 30818) A skate spot is an addressable event that represents a skateboarding location anywhere in the world. Users can create, share, and discover skate spots. ### Event Format ```json { "kind": 30818, "content": "", "tags": [ ["d", ""], ["title", ""], ["g", ""], ["location", ""], ["category", ""], ["t", "skatespot"], ["t", ""], ["t", ""], ["t", ""], ["imeta", "url ", "m image/jpeg"], ["alt", "Skate spot: "] ] } ``` ### Required Tags | Tag | Description | |-----|-------------| | `d` | Unique identifier for the spot (UUID or slug) | | `title` | Human-readable name of the skate spot | | `g` | Geohash encoding the spot's geographic coordinates (minimum 6 characters for ~1km precision) | | `category` | Primary spot category (see Spot Categories below) | | `t` | Must include `skatespot` for discoverability | | `alt` | Human-readable description for clients that don't understand this kind (NIP-31) | ### Optional Tags | Tag | Description | |-----|-------------| | `location` | Human-readable address or location description | | `imeta` | Image metadata per NIP-92 | | `lat` | Latitude (decimal) | | `lon` | Longitude (decimal) | | `access` | Access type: `public`, `private`, `locals` | | `shelter` | Weather-protection level: `outdoor` (default), `partial`, `covered`, `indoor` (see Shelter below) | | `contact` | Contact info for the spot | | `t` | Freeform hashtags for features, surfaces, vibes (max 20) | ### Spot Categories The `category` tag defines the primary type of spot. Only ONE category per spot: | Value | Description | |-------|-------------| | `street` | Street spots (stairs, rails, ledges, etc.) | | `park` | Skateparks (public or private) | | `diy` | DIY/homemade spots | | `plaza` | Public plazas with skate-friendly architecture | ### Shelter (weather protection) The optional `shelter` tag records how protected a spot is from the weather, so clients can avoid misleading skaters (e.g. showing "bad conditions" for an indoor park just because it's raining outside). Exactly one value: | Value | Description | |-------|-------------| | `outdoor` | Fully exposed — the default; rain stops play | | `partial` | Partly covered — some obstacles/flat stay dry | | `covered` | Outdoor but under a roof (car park, band shell, overpass) | | `indoor` | Fully enclosed facility — skateable rain or shine | **Relay-level filtering:** sheltered spots (`partial`, `covered`, `indoor`) also carry a matching `t` tag (e.g. `["t", "indoor"]`) so clients can query "dry spots" efficiently with `#t`. `outdoor` is the implicit default and adds **no** `t` tag, to avoid tagging the vast majority of street spots. When `shelter` is absent, clients should treat the spot as `outdoor`. ### Freeform Tags (`t` tags) In addition to the required `skatespot` tag, spots can have up to **20 freeform tags** for detailed categorization. Tags are stored lowercase without `#` prefix. #### Feature Tags (obstacles/terrain) `ledge`, `rail`, `handrail`, `flatrail`, `kinked-rail`, `rainbow-rail`, `stairs`, `gap`, `bank`, `quarter`, `halfpipe`, `bowl`, `pool`, `miniramp`, `vert`, `hubba`, `manual-pad`, `flatground`, `curb`, `bump`, `pyramid`, `euro-gap`, `kicker`, `hip`, `spine`, `cradle`, `snake-run`, `pump-track`, `wallride`, `drop`, `loading-dock` #### Surface Tags (materials) `concrete`, `marble`, `granite`, `brick`, `metal`, `wood`, `asphalt`, `tile`, `smooth`, `rough`, `crusty`, `slippery`, `painted` #### Size Tags `small`, `medium`, `large`, `massive`, `mellow`, `steep`, `low`, `high`, `long`, `short` #### Stair Counts `2-stair`, `3-stair`, `4-stair`, `5-stair`, `6-stair`, `7-stair`, `8-stair`, `9-stair`, `10-stair`, `10-plus-stair` #### Vibe/Condition Tags `chill`, `famous`, `classic`, `hidden-gem`, `locals-only`, `beginner-friendly`, `intermediate`, `advanced`, `pro-spot`, `night-spot`, `covered`, `lit`, `shaded`, `security`, `bust-factor`, `skate-stopped`, `well-maintained`, `run-down` #### Location Context Tags `downtown`, `industrial`, `residential`, `campus`, `waterfront`, `rooftop`, `underground`, `parking-garage`, `school`, `business-district` ### Tag Normalization Rules 1. **Lowercase**: All tags stored in lowercase (`Ledge` → `ledge`) 2. **No spaces**: Use hyphens for multi-word tags (`night spot` → `night-spot`) 3. **No special characters**: Alphanumeric and hyphens only 4. **No `#` prefix**: Tags stored without hash (`ledge` not `#ledge`) 5. **Max 20 tags**: Prevent spam/over-tagging ### Example ```json { "kind": 30818, "pubkey": "abc123...", "created_at": 1703001600, "content": "Classic LA spot with a perfect 10-stair and hubba ledge. Usually busy on weekends. Security is chill during the day but kicks out after 6pm.", "tags": [ ["d", "hollywood-high"], ["title", "Hollywood High School"], ["g", "9q5ctr"], ["location", "1521 N Highland Ave, Los Angeles, CA"], ["category", "street"], ["access", "public"], ["t", "skatespot"], ["t", "stairs"], ["t", "10-stair"], ["t", "hubba"], ["t", "ledge"], ["t", "marble"], ["t", "famous"], ["t", "classic"], ["t", "pro-spot"], ["t", "security"], ["imeta", "url https://example.com/hollywood-high.jpg", "m image/jpeg"], ["lat", "34.1017"], ["lon", "-118.3385"], ["alt", "Skate spot: Hollywood High School"] ], "id": "...", "sig": "..." } ``` An indoor spot additionally carries the `shelter` tag plus its matching `t` tag so it surfaces in "dry spots" queries: ```json { "kind": 30818, "content": "Warehouse park — full street course, open when it rains.", "tags": [ ["d", "the-warehouse"], ["title", "The Warehouse"], ["g", "gcpvj0"], ["category", "park"], ["access", "public"], ["shelter", "indoor"], ["t", "skatespot"], ["t", "indoor"], ["alt", "Skate spot: The Warehouse"] ] } ``` ## Querying Skate Spots ### All Spots ```json { "kinds": [30818], "#t": ["skatespot"] } ``` ### Spots by Feature Tag ```json { "kinds": [30818], "#t": ["skatespot", "ledge"] } ``` ### Spots by Multiple Tags ```json { "kinds": [30818], "#t": ["skatespot", "marble", "ledge"] } ``` ### Spots by Geohash Prefix (Area Search) ```json { "kinds": [30818], "#g": ["9q5"] } ``` Note: Relays may or may not support geohash prefix queries. Clients should fall back to client-side filtering if needed. ### Client-Side Category Filtering Since `category` is not a single-letter tag, filter by category client-side after fetching spots: ```typescript const streetSpots = spots.filter(spot => spot.tags.find(([k, v]) => k === 'category' && v === 'street') ); ``` ## Skate Events Skateboarding events use the standard NIP-52 time-based calendar event (kind 31923) with the hashtag `#skateboarding`: ```json { "kind": 31923, "content": "<event description>", "tags": [ ["d", "<unique-id>"], ["title", "<event name>"], ["start", "<unix-timestamp>"], ["end", "<unix-timestamp>"], ["g", "<geohash>"], ["location", "<venue address>"], ["t", "skateboarding"], ["t", "<event-type>"] ] } ``` ### Event Types Common `t` tags for skate events: - `contest` - Skateboarding competitions - `demo` - Pro demos - `session` - Group sessions - `workshop` - Lessons or workshops - `premiere` - Video premieres - `tradeshow` - Industry events - `gameskate` - Game of S.K.A.T.E. competitions #### Music & Culture Events Skateboarding and music culture are deeply connected. These event types support concerts, jams, and cultural gatherings: - `concert` - Live music performances at skate venues - `jam` - Skate jams with music, art, and community vibes - `festival` - Multi-day skateboarding festivals (e.g., Tampa Pro, Dew Tour) - `popup` - Pop-up events, brand activations, and temporary installations Events can have multiple type tags. For example, a video premiere with a live band would have both `premiere` and `concert` tags. ### Event Sponsors Events may carry sponsor logos (a "Presented by" strip), mirroring the tour sponsor format: ```json ["sponsor", "<tier>", "<name>", "<logo-url>", "<website-url>"] ``` - `<tier>` is one of `title`, `gold`, `silver`, or `partner` (controls ordering + emphasis; `title` gets a hero row). - `<name>` is required; `<logo-url>` and `<website-url>` are optional. - Multiple `sponsor` tags are allowed. ### Event RSVPs (Attendance) Attendance uses the standard **NIP-52 RSVP** (kind `31925`). A skater responds to a calendar event with `accepted` (going), `tentative` (interested), or `declined` (can't make it). RSVPs are addressable, so publishing a new one with the same `d` replaces the prior status — letting a skater freely switch. ```json { "kind": 31925, "content": "<optional note>", "tags": [ ["d", "<unique-id>"], ["a", "31923:<event-author-pubkey>:<event-d>", "", ""], ["e", "<event-id>"], ["status", "accepted"], ["p", "<event-author-pubkey>"], ["fb", "busy"], ["alt", "RSVP (accepted) to a skate event"] ] } ``` - The `a` tag references the calendar event coordinate being responded to (required). - The `p` tag targets the host so hosts can query RSVPs addressed to them (and get notified). - `fb` (`free`/`busy`) is omitted for `declined` per NIP-52. Clients query an event's RSVPs with `{ kinds: [31925], "#a": ["31923:<host>:<d>"] }` and keep the newest per pubkey. Hosts see incoming RSVPs in their notification center via `{ kinds: [31925], "#p": ["<host-pubkey>"] }`. ### Event Updates (Host broadcasts) Hosts broadcast changes ("rain — moved to Sunday", "meet at the back ledge") with a **SkateSpots-native Event Update** (kind `7103`, regular event) pinned to a calendar event. Every current RSVPer is `p`-tagged so they get a notification; anyone viewing the event also sees the update thread. ```json { "kind": 7103, "content": "Rain — moved to Sunday 2pm. Meet at the back ledge.", "tags": [ ["a", "31923:<event-author-pubkey>:<event-d>"], ["e", "<event-id>"], ["update-kind", "reschedule"], ["t", "skateboarding"], ["admin", "true"], ["p", "<rsvper-pubkey>"], ["p", "<rsvper-pubkey>"], ["alt", "Update to a skate event"] ] } ``` - `update-kind` is one of `update` (default), `reschedule`, `relocation`, or `cancelled` — used for iconography/emphasis. - **Trust rule (required):** an update is only honored if its author is **either** the event's own host (the pubkey embedded in the `a` coordinate) **or** a designated SkateSpots admin. Updates from any other pubkey MUST be discarded — otherwise a stranger could forge a fake "cancelled" notice. Clients enforce this both when rendering the update thread and when raising notifications. - `["admin", "true"]` marks a moderation action posted by an admin on an event they don't own. Clients render such updates with an explicit "Admin" label so they are never mistaken for the host. - When a host edits an event's **time or location**, the client auto-publishes a `reschedule`/`relocation` update to notify RSVPers. Clients query an event's updates with `{ kinds: [7103], "#a": ["31923:<host>:<d>"] }`, then filter authors to `{host} ∪ {admins}`. ## Social Interaction Comments and reactions to skate spots and events use standard Nostr kinds: - **Reactions** (kind 7): React to spots or events with emoji - **Comments** (kind 1111): Comment on spots or events per NIP-22 - **Zaps** (kind 9735): Send sats to spot creators or event organizers ## Spot Collections (kind 30003) Spot collections use NIP-51 "Bookmark sets" (kind 30003) to allow users to organize skate spots into personalized lists. This enables interoperability with other Nostr bookmark clients. ### Event Format ```json { "kind": 30003, "content": "<collection description>", "tags": [ ["d", "<unique-identifier>"], ["title", "<collection name>"], ["t", "skatespots"], ["description", "<collection description>"], ["image", "<cover image URL>"], ["a", "30818:<pubkey>:<spot-d-tag>"], ["a", "30818:<pubkey>:<spot-d-tag>"], ["alt", "Spot collection: <title>"] ] } ``` ### Required Tags | Tag | Description | |-----|-------------| | `d` | Unique identifier for the collection | | `title` | Collection name | | `t` | Must include `skatespots` to identify as a spot collection | ### Optional Tags | Tag | Description | |-----|-------------| | `description` | Description of the collection | | `image` | Cover image URL | | `a` | Reference to a skate spot (format: `30818:pubkey:d-tag`) | | `alt` | Human-readable description for clients that don't understand this kind | ### Example ```json { "kind": 30003, "pubkey": "abc123...", "created_at": 1703001600, "content": "My favorite spots from my Barcelona trip in summer 2024", "tags": [ ["d", "barcelona-trip-2024-1703001600"], ["title", "Barcelona Trip 2024"], ["t", "skatespots"], ["description", "My favorite spots from my Barcelona trip in summer 2024"], ["image", "https://example.com/barcelona-cover.jpg"], ["a", "30818:abc123:macba"], ["a", "30818:def456:parallel-plaza"], ["a", "30818:ghi789:sants-station"], ["alt", "Spot collection: Barcelona Trip 2024"] ], "id": "...", "sig": "..." } ``` ### Querying Collections ```json { "kinds": [30003], "authors": ["<user-pubkey>"], "#t": ["skatespots"] } ``` ## Gear Listings (kind 30402) Gear listings use the NIP-99 Classified Listings format (kind 30402) for peer-to-peer skateboarding gear sales. This enables skaters to buy and sell used equipment directly at skate spots. ### Event Format ```json { "kind": 30402, "content": "<detailed description>", "tags": [ ["d", "<unique-identifier>"], ["title", "<item name>"], ["t", "skateboarding"], ["t", "<category>"], ["price", "<amount>", "<currency>"], ["price_type", "<fixed|free|trade|offer>"], ["condition", "<new|like_new|good|well_used>"], ["listing_type", "<selling|wtb>"], ["size", "<size-value>"], ["g", "<geohash>"], ["location", "<pickup location>"], ["a", "30818:<pubkey>:<spot-d-tag>"], ["available", "<when seller is available>"], ["expiration", "<unix-timestamp>"], ["status", "active"], ["published_at", "<unix-timestamp>"], ["image", "<image-url>"], ["alt", "Gear listing: <title>"] ] } ``` ### Required Tags | Tag | Description | |-----|-------------| | `d` | Unique identifier for the listing | | `title` | Item name | | `t` | Must include `skateboarding` for discoverability | | `t` | Category tag (see categories below) | | `image` | At least one product photo | | `price_type` | How the item is being sold: `fixed`, `free`, `trade`, `offer` | | `listing_type` | Whether selling or buying: `selling` or `wtb` (Want to Buy) | ### Optional Tags | Tag | Description | |-----|-------------| | `price` | Price amount and currency (e.g., `["price", "50", "USD"]`) - required if price_type is `fixed` | | `condition` | Item condition: `new`, `like_new`, `good`, `well_used` | | `size` | Item size (varies by category) | | `fulfilment` | How the deal is fulfilled: `pickup`, `ships`, or `both`. ⚠️ The app **never collects an address** — a `ships` deal exchanges it privately in the trade chat (NIP-17). | | `shipping` | Who pays postage when it ships: `buyer`, `seller`, or `split`. | | `g` | Geohash for pickup location | | `location` | Human-readable pickup location | | `a` | Reference to a skate spot (format: `30818:pubkey:d-tag`) | | `available` | When the seller is typically available | | `expiration` | Unix timestamp when listing expires (default: 30 days) | | `status` | Listing status: `active`, `sold`, `pending` | | `summary` | Short one-liner about the item | ### Categories The following `t` tags are used for categorizing gear: - `deck` - Skateboard decks - `trucks` - Trucks - `wheels` - Wheels - `bearings` - Bearings - `shoes` - Skate shoes - `clothing` - Clothing and apparel - `protective` - Protective gear (helmets, pads, etc.) - `stickers` - Stickers - `wax` - Skate wax - `tools` - Skate tools - `griptape` - Griptape - `rubbrick` - Rub bricks ### Condition Values | Value | Label | Description | |-------|-------|-------------| | `new` | 🆕 New | Never used, still in packaging | | `like_new` | 👍 Like New | Barely used, excellent condition | | `good` | ✅ Good | Normal wear, functions perfectly | | `well_used` | ⚠️ Well Used | Shows wear but still works | ### Price Types | Value | Label | Description | |-------|-------|-------------| | `fixed` | Fixed Price | Set price in fiat or sats | | `free` | Free | Giving away | | `trade` | Trade | Looking to swap | | `offer` | Make Offer | Price negotiable | ### Size Options by Category **Decks:** - `7.5"`, `7.75"`, `8.0"`, `8.125"`, `8.25"`, `8.38"`, `8.5"`, `8.75"`, `9.0"+` **Trucks:** - Sizes: `129`, `139`, `144`, `149`, `159`, etc. - Heights: `low`, `mid`, `high`, `inverted` **Shoes:** - US sizes: `6`, `6.5`, `7`, `7.5`, ... `14` - EU sizes: `38`, `39`, `40`, ... `48` **Clothing:** - `XS`, `S`, `M`, `L`, `XL`, `XXL` **Wheels:** - Diameter: `50mm`, `52mm`, `54mm`, `56mm`, `58mm`, `60mm` - Hardness: `78a`, `87a`, `92a`, `97a`, `99a`, `101a` ### Listing Type | Value | Description | |-------|-------------| | `selling` | User is selling gear | | `wtb` | Want to Buy - User is looking for specific gear | ### Expiration Listings should include an `expiration` tag with a Unix timestamp. Default expiration periods: - 7 days - 14 days - 30 days (default) Expired listings should be filtered out by clients. ### Spot Association Gear listings can be tied to specific skate spots using an `a` tag referencing a spot event. This allows: - Listings to appear on spot detail pages - Easy meetup coordination at familiar locations - Geographic filtering of gear listings **Important:** Gear listings should NOT appear at shop locations (conflict of interest). Only show gear at regular skate spots or dual shop+spot locations. ### NIP-05 Verification For trust and spam prevention, sellers should have a verified NIP-05 identifier. Clients may: - Require NIP-05 to create listings - Display verification badges on listings - Allow filtering by verified sellers ### Example: Selling a Deck ```json { "kind": 30402, "pubkey": "abc123...", "created_at": 1703001600, "content": "Got this deck for Christmas but prefer a smaller size. Happy to let you try it before buying! Some minor scratches from one session but graphics are clean.", "tags": [ ["d", "listing-1703001600"], ["title", "Palace Deck 8.25\""], ["t", "skateboarding"], ["t", "deck"], ["price", "40", "USD"], ["price_type", "fixed"], ["condition", "like_new"], ["listing_type", "selling"], ["size", "8.25\""], ["g", "9q5ctr"], ["location", "Venice Beach Skatepark"], ["a", "30818:def456:venice-beach-park"], ["available", "Weekends 10am-2pm"], ["expiration", "1705593600"], ["status", "active"], ["published_at", "1703001600"], ["image", "https://example.com/deck-front.jpg"], ["image", "https://example.com/deck-back.jpg"], ["alt", "Gear listing: Palace Deck 8.25\""] ], "id": "...", "sig": "..." } ``` ### Example: Want to Buy (WTB) ```json { "kind": 30402, "pubkey": "xyz789...", "created_at": 1703001600, "content": "Looking for a solid 8.0\" deck, any brand. Prefer minimal graphics or blank. Can pay cash or trade wheels.", "tags": [ ["d", "listing-1703001700"], ["title", "Looking for 8.0\" Deck"], ["t", "skateboarding"], ["t", "deck"], ["price_type", "offer"], ["listing_type", "wtb"], ["size", "8.0\""], ["g", "9q5ctr"], ["location", "Near Downtown Rails"], ["expiration", "1705593600"], ["status", "active"], ["published_at", "1703001600"], ["alt", "WTB: Looking for 8.0\" Deck"] ], "id": "...", "sig": "..." } ``` ### Querying Gear Listings **All Active Listings:** ```json { "kinds": [30402], "#t": ["skateboarding"] } ``` **Listings by Category:** ```json { "kinds": [30402], "#t": ["skateboarding", "deck"] } ``` **Listings at a Spot:** ```json { "kinds": [30402], "#a": ["30818:pubkey:spot-id"], "#t": ["skateboarding"] } ``` **Listings Near Location (using geohash prefix):** ```json { "kinds": [30402], "#g": ["9q5"], "#t": ["skateboarding"] } ``` ## Marketplace Offers (kind 30403) A structured **negotiation** over a listing. Either side can offer, counter, accept, decline, or withdraw — a back-and-forth thread, not a single request. **Every message is its own event with a UNIQUE `d`**, so counters never overwrite the message they answer (the thread is append-only). The current state of a negotiation is simply its newest message. | Field | Value | | --- | --- | | `d` | `<author>:<role>:<listing-d>:<nonce>` — **unique**, one per message | | `a` | the listing coord (`30402:<seller>:<d>`) | | `p` | the **other party** (offer → seller; response → buyer) | | `e` | the message this one answers (a counter / accept / decline) — absent only on the first offer | | `role` | `offer` (authored by the buyer) \| `response` (authored by the seller) | | `status` | `open` \| `accepted` \| `declined` \| `withdrawn` | | `amount` | optional `["amount","45000","SATS"]` | | `item` | optional `["item","my 8.25 deck + spare wheels"]` — an **in-kind offer (barter)**; may be combined with `amount` | | `payment` | On an **`accepted`** message: `["payment", "<value>", "<kind>"]` — where the buyer sends sats. `kind` ∈ `lninvoice` \| `lnaddress` \| `creq` \| `clink`. | | `payment_hash` | Optional `["payment_hash","<hex>"]` when `kind=lninvoice` — a proof reference for the `31871`. | | `alt` | `SkateSpots: marketplace offer` | `content` is an optional human message. Either party may answer the newest `open` message with a **counter** (`status=open`, new `amount`, `e` = the message being answered), or **accept/decline** it. Negotiations are grouped by `(listing, buyer)` into threads; a thread is "awaiting" whoever did not author its newest `open` message. > ⚠️ Legacy events used fixed slots (`d = <buyer>:offer:<listing-d>` and > `<seller>:offer-response:<buyer>`, where a re-publish replaced the message). > Clients MUST still parse those (`role` inferred from the `d` marker when the > `role` tag is absent), but new messages use the unique-`d` thread form. Payment is still arranged **directly between the two skaters** — SkateSpots never holds funds. To make that easier, when a seller **accepts** they may attach a **`payment` pointer** for the buyer to pay in-app (a BOLT11 invoice, their Lightning address, a NUT-18 `creq`, or a CLINK code). The pointer rides the accepted message itself, so it flows through the same event + notification path as the offer — no DM, and nothing to decrypt to show it. If the accept carries no pointer, clients fall back to the seller's profile `lud16`. `lninvoice` / `lnaddress` are payable today; `creq` / `clink` are recognized but not yet payable. **Bartering is first-class**: an offer or counter may carry an `item` (what the sender is offering) instead of, or alongside, `amount` — and listings already support `price_type=trade`. ## Community Contributions Community members can contribute to spots without being the owner. Contributions are stored as separate events that reference the original spot. ### Photo Contributions (kind 1063) Community photos use NIP-94 File Metadata (kind 1063) with an `a` tag referencing the spot. ```json { "kind": 1063, "content": "Great ledge for slappies!", "tags": [ ["url", "https://example.com/photo.jpg"], ["m", "image/jpeg"], ["a", "30818:<spot-pubkey>:<spot-d-tag>"], ["alt", "Community photo for Hollywood High"] ] } ``` ### Tag Suggestions (kind 1111) Tag suggestions use NIP-22 Comments (kind 1111) with a special marker tag. ```json { "kind": 1111, "content": "Suggested tags for Hollywood High: #marble #ledge #10-stair", "tags": [ ["a", "30818:<spot-pubkey>:<spot-d-tag>"], ["K", "30818"], ["t", "skatespot-tags"], ["t", "marble"], ["t", "ledge"], ["t", "10-stair"] ] } ``` The `skatespot-tags` marker distinguishes tag suggestions from regular comments. ### Querying Contributions **Community Photos:** ```json { "kinds": [1063], "#a": ["30818:pubkey:spot-id"] } ``` **Tag Suggestions:** ```json { "kinds": [1111], "#a": ["30818:pubkey:spot-id"], "#t": ["skatespot-tags"] } ``` ## Spot Ownership Transfer Spot ownership can be transferred in two ways: ### Voluntary Transfer The owner publishes a transfer request as a kind 1111 comment: ```json { "kind": 1111, "content": "Ownership transfer request for spot \"Hollywood High\"", "tags": [ ["a", "30818:<owner-pubkey>:<spot-d-tag>"], ["K", "30818"], ["t", "spot-transfer"], ["p", "<new-owner-pubkey>", "", "new-owner"] ] } ``` ### Abandoned Spot Claim If a spot hasn't been updated for 6+ months, any user can claim it by: 1. Re-publishing the spot under their own pubkey 2. Adding `claim-from` and `claim-reason` tags ```json { "kind": 30818, "content": "<original description>", "tags": [ ["d", "<same-d-tag>"], ["title", "<same-title>"], // ... other original tags ... ["claim-from", "<original-owner-pubkey>"], ["claim-reason", "abandoned"] ] } ``` Note: Since Nostr is decentralized, the original spot remains. Clients should prefer the most recently updated version or allow users to choose. ## Skate Tours (kind 30823) Skate tours allow experienced skaters to organize and host various types of skateboarding adventures. Tours range from local hidden spot tours to multi-country expeditions and KOTR-style World Destruction competitions. ### Tour Categories | Category | Emoji | Description | Typical Duration | |----------|-------|-------------|------------------| | `local` | 🏠 | Show hidden spots in your city | 2-8 hours | | `national` | 🚐 | Multi-city road trips across the country | 2-14 days | | `international` | ✈️ | Cross-border adventures across multiple countries | 1-8 weeks (up to 2 months) | | `destruction` | 💀 | World Destruction Tour - KOTR-style global team competition | 1-12 months | ### Event Format (Base) ```json { "kind": 30823, "content": "<tour description - what skaters will experience>", "tags": [ ["d", "<unique-identifier>"], ["title", "<tour name>"], ["t", "skate-tour"], ["tour-category", "<local|national|international|destruction>"], ["t", "<spot-type>"], ["g", "<geohash>"], ["location", "<city/region>"], ["meeting-point", "<public meeting location name>"], ["meeting-point-coords", "<lat>", "<lon>"], ["duration", "<number>", "<hours|days|weeks>"], ["skill-level", "<beginner|intermediate|advanced|all>"], ["max-group", "<max participants>"], ["price", "<sats>"], ["available", "<availability description>"], ["languages", "<lang1>", "<lang2>"], ["shop", "<partner-shop-naddr>"], ["shop-discount", "<percentage>"], ["zap-split", "skatespots@minibits.cash", "10"], ["image", "<tour-image-url>"], ["alt", "Skate Tour: <title> in <location>"] ] } ``` ### Required Tags | Tag | Description | |-----|-------------| | `d` | Unique identifier for the tour | | `title` | Tour name | | `t` | Must include `skate-tour` to identify as a tour event | | `tour-category` | Tour type: `local`, `national`, `international`, or `destruction` | | `location` | City or region where tour operates/starts | ### Optional Tags (All Categories) | Tag | Description | |-----|-------------| | `g` | Geohash for location (low precision - 4 chars recommended) | | `meeting-point` | Public meeting location name | | `meeting-point-coords` | Latitude and longitude of meeting point | | `duration` | Tour length with unit: `["duration", "3", "hours"]` or `["duration", "5", "days"]` | | `skill-level` | Recommended skill level: `beginner`, `intermediate`, `advanced`, or `all` | | `max-group` | Maximum number of participants | | `price` | Price per person in satoshis (host decides) | | `available` | When host is available for a flexible/by-appointment local tour (e.g., "Weekends", "By appointment") | | `start-date` | Unix timestamp of the tour's scheduled start. For **local** tours this is a specific session date+time; for **national/international** it is the trip departure day; for **destruction** it is the competition start (challenges reveal). Omit for flexible local tours. | | `end-date` | Unix timestamp of the tour's end/return day (national, international, destruction). | | `languages` | Languages the host speaks | | `shop` | Reference to partner skate shop (naddr) | | `shop-discount` | Percentage discount at partner shop for tour participants | | `zap-split` | Platform fee recipient and percentage | | `image` | Tour or host photo | ### Spot Type Tags Tours can include multiple spot type tags to describe what kind of terrain will be covered: - `street` - Street spots - `park` - Skateparks - `diy` - DIY spots - `transition` - Transition/bowl spots - `hidden` - Hidden gems ### National & International Tour Tags | Tag | Description | |-----|-------------| | `cities` | List of cities: `["cities", "LA", "SF", "Portland", "Seattle"]` | | `countries` | List of countries: `["countries", "Spain", "France", "Germany"]` | | `transport` | Transport information/recommendations | | `accommodation` | Accommodation information/recommendations | ### World Destruction Tour Tags World Destruction Tours are KOTR-style global team competitions where organizers define EVERYTHING - challenges, rules, prizes. The platform only provides infrastructure. **Key Characteristics:** - Duration: 1-12 months - Skill level: Always **advanced** (hardcoded) - Spot types: **ALL types included** automatically - Challenges: **SECRET until competition start date** | Tag | Description | |-----|-------------| | `cities` | List of competition cities: `["cities", "Berlin", "Barcelona", "Tokyo"]` | | `countries` | List of countries: `["countries", "Germany", "Spain", "Japan"]` | | `organizer-contact` | Organizer contact details (Nostr npub, email, Telegram, etc.) | | `transport` | Transport information/recommendations | | `start-date` | Unix timestamp when competition starts (**challenges revealed on this date**) | | `end-date` | Unix timestamp when competition ends | | `entry-fee` | Entry fee per team in satoshis (organizer decides, can be 0) | | `prize-pool` | Total prize pool in satoshis | | `prize-dist` | Prize distribution: `["prize-dist", "1", "50", "2", "30", "3", "20"]` (place, percentage pairs) | | `max-teams` | Maximum number of teams allowed | | `min-team-size` | Minimum skaters per team | | `max-team-size` | Maximum skaters per team | | `challenge` | Challenge definition (multiple allowed): `["challenge", "id", "title", "description", "points", "revealDate"]` | | `clip-sources` | Accepted clip sources: `["clip-sources", "blossom", "youtube", "instagram", "any"]` | ### Challenge Tag Format ```json ["challenge", "<id>", "<title>", "<description>", "<points>", "<revealDate>"] ``` - `id`: Unique challenge identifier - `title`: Challenge name (e.g., "Kickflip a 10 stair") - `description`: Optional detailed description - `points`: Points awarded for completion - `revealDate`: Unix timestamp when challenge becomes visible (defaults to tour start date - **ALL challenges are SECRET until this date**) ### Revenue Model **Hosts set their own prices.** Platform commission varies by tour type via `zap-split` tag. | Tour Type | Platform Fee | Host Keeps | |-----------|--------------|------------| | Local | 10% | 90% | | National | 7% | 93% | | International | 5% | 95% | | World Destruction | 5% | 95% | **Important:** For World Destruction Tours, the 5% fee applies only to **entry fees**, NOT to sponsor money or prize pools. Example zap-split tags: - Local: `["zap-split", "skatespots@minibits.cash", "10"]` - National: `["zap-split", "skatespots@minibits.cash", "7"]` - International/Destruction: `["zap-split", "skatespots@minibits.cash", "5"]` ### Sponsor Tags Tours can have sponsors. Sponsors are stored as tags and displayed on tour pages. ```json ["sponsor", "<tier>", "<name>", "<logo-url>", "<website-url>", "<challengeId?>"] ``` | Field | Description | |-------|-------------| | `tier` | Sponsor level: `title`, `gold`, `silver`, `challenge`, `partner` | | `name` | Sponsor name (required) | | `logo-url` | URL to sponsor logo image (optional) | | `website-url` | Sponsor website URL (optional) | | `challengeId` | For challenge sponsors only - which challenge they sponsor | **Sponsor Tiers:** - `title` - Title/naming sponsor (e.g., "The Vans World Destruction Tour") - `gold` - Major sponsor with prominent placement - `silver` - Secondary sponsor - `challenge` - Sponsors a specific challenge - `partner` - General partner/supporter Example: ```json ["sponsor", "title", "Vans", "https://vans.com/logo.png", "https://vans.com"], ["sponsor", "gold", "Monster Energy", "https://monster.com/logo.png", "https://monster.com"], ["sponsor", "challenge", "Red Bull", "https://redbull.com/logo.png", "https://redbull.com", "ch3"] ``` ### Privacy Model **Critical**: Hidden spots are NEVER published to Nostr. They are shared in-person during the tour only. - Meeting point is a known public location - City-level geohash provides general location for discovery - Actual hidden spots are revealed only during the tour ### Video/Clip Submissions For destruction tours, organizers can accept clip submissions as proof of challenge completion: 1. **Blossom uploads** - For clips under 50MB 2. **External links** - YouTube, Instagram, or any URL Organizers review submissions and award points manually. The platform does NOT host video - it only stores references. ### Example: Local Tour ```json { "kind": 30823, "pubkey": "abc123...", "created_at": 1703001600, "content": "Join me for a 3-hour street spot tour of Barcelona's Gothic Quarter.", "tags": [ ["d", "barcelona-gothic-hidden-tour"], ["title", "Barcelona Gothic Quarter - Hidden Gems Tour"], ["t", "skate-tour"], ["tour-category", "local"], ["t", "street"], ["t", "hidden"], ["g", "sp3e"], ["location", "Barcelona, Spain"], ["meeting-point", "MACBA Museum Steps"], ["meeting-point-coords", "41.3833", "2.1670"], ["duration", "3", "hours"], ["skill-level", "intermediate"], ["max-group", "4"], ["price", "25000"], ["languages", "en", "es", "ca"], ["zap-split", "skatespots@minibits.cash", "10"], ["alt", "Skate Tour: Barcelona Gothic Quarter in Barcelona, Spain"] ] } ``` ### Example: National Tour ```json { "kind": 30823, "pubkey": "def456...", "created_at": 1703001600, "content": "5-day California coast tour hitting the best spots from LA to SF. Van included, bring your own board!", "tags": [ ["d", "california-coast-tour-2024"], ["title", "California Coast Tour 2024"], ["t", "skate-tour"], ["tour-category", "national"], ["t", "street"], ["t", "park"], ["g", "9q5"], ["location", "California, USA"], ["cities", "Los Angeles", "Santa Barbara", "San Luis Obispo", "Santa Cruz", "San Francisco"], ["meeting-point", "Venice Beach Skatepark"], ["meeting-point-coords", "33.9850", "-118.4695"], ["duration", "5", "days"], ["skill-level", "intermediate"], ["max-group", "8"], ["price", "500000"], ["transport", "15-passenger van, driver included"], ["accommodation", "Budget hostels along the route - not included in price"], ["languages", "en", "es"], ["zap-split", "skatespots@minibits.cash", "10"], ["alt", "Skate Tour: California Coast Tour 2024"] ] } ``` ### Example: World Destruction Tour ```json { "kind": 30823, "pubkey": "ghi789...", "created_at": 1703001600, "content": "EUROPEAN WORLD DESTRUCTION TOUR 2024. 4 teams battle across Europe's gnarliest spots for 3 months. Complete challenges, film clips, earn points. Winner takes all!", "tags": [ ["d", "europe-destruction-2024"], ["title", "European World Destruction Tour 2024"], ["t", "skate-tour"], ["tour-category", "destruction"], ["t", "street"], ["t", "park"], ["t", "diy"], ["t", "transition"], ["t", "hidden"], ["cities", "Berlin", "Barcelona", "Paris", "London", "Amsterdam"], ["countries", "Germany", "Spain", "France", "UK", "Netherlands"], ["organizer-contact", "DM on Nostr: npub1... or email: destruction@skatespots.org"], ["transport", "Teams arrange own transport between cities"], ["duration", "12", "weeks"], ["skill-level", "advanced"], ["start-date", "1720000000"], ["end-date", "1727776000"], ["entry-fee", "100000"], ["prize-pool", "5000000"], ["prize-dist", "1", "50", "2", "30", "3", "20"], ["max-teams", "8"], ["min-team-size", "4"], ["max-team-size", "6"], ["challenge", "ch1", "First kickflip at Warschauer", "Land a kickflip on the famous Warschauer banks", "100", "1720000000"], ["challenge", "ch2", "Get a local to join", "Convince a local skater to join your session", "200", "1720000000"], ["challenge", "ch3", "Fountain trick", "Land a trick into the Alexanderplatz fountain", "500", "1720000000"], ["challenge", "ch4", "3-country video part", "Film a video part spanning at least 3 countries", "1000", "1720000000"], ["challenge", "ch5", "MACBA line", "Film a full line at MACBA Barcelona", "777", "1720000000"], ["clip-sources", "blossom", "youtube", "instagram", "any"], ["languages", "en", "de", "es", "fr"], ["zap-split", "skatespots@minibits.cash", "10"], ["alt", "World Destruction Tour: European World Destruction Tour 2024"] ] } ``` **Note:** All challenges have the same `revealDate` (1720000000) which is the tour start date. Teams cannot see challenge details until this date. ### Querying Tours **All Tours:** ```json { "kinds": [30823], "#t": ["skate-tour"] } ``` **Tours by Category:** ```json { "kinds": [30823], "#t": ["skate-tour"] } ``` Note: Filter by `tour-category` tag client-side. **Tours in a City (by geohash prefix):** ```json { "kinds": [30823], "#t": ["skate-tour"], "#g": ["sp3"] } ``` **Tours by a Host:** ```json { "kinds": [30823], "authors": ["<host-pubkey>"], "#t": ["skate-tour"] } ``` ## Food Recommendations Food spots near skate locations are handled client-side by querying external APIs (like OpenStreetMap/Overpass) using the geohash coordinates from skate spots. ## Private Stash (kind 30001) The Stash feature uses NIP-51 Follow Sets (kind 30001) to store a user's private gear inventory. The content is NIP-44 encrypted to self, making it private by default with selective disclosure options. ### Event Format ```json { "kind": 30001, "content": "<NIP-44 encrypted JSON>", "tags": [ ["d", "skatespots-stash"], ["alt", "Private gear stash (encrypted)"] ] } ``` ### Encrypted Content Structure When decrypted, the content contains a JSON object with the user's inventory: ```json { "items": [ { "id": "<uuid>", "title": "Powell Peralta Flight Deck 8.25", "category": "deck", "condition": "like_new", "size": "8.25\"", "brand": "Powell Peralta", "description": "Barely used, no chips or cracks", "minPrice": 50000, "maxPrice": null, "images": ["https://example.com/deck.jpg"], "willingToTrade": true, "willingToSell": true, "addedAt": 1703001600, "updatedAt": 1703001600, "visibility": "friends", "itemType": "stash", "rarity": "rare" }, { "id": "<uuid>", "title": "Looking for 8.0\" deck", "category": "deck", "condition": "good", "size": "8.0\"", "brand": "", "description": "Any brand, prefer minimal graphics", "minPrice": null, "maxPrice": 100000, "images": [], "willingToTrade": true, "willingToSell": false, "addedAt": 1703002600, "visibility": "friends", "itemType": "wtb", "rarity": null } ], "updatedAt": 1703002600, "deleted": [{ "id": "<uuid of a removed item>", "at": 1703003000 }] } ``` ### Multi-device merge (`updatedAt` + `deleted`) The stash is ONE replaceable blob, so a naive last-write-wins publish silently drops whatever another device added. Clients therefore **merge** instead: - Every item carries `updatedAt` (epoch seconds, last edit). Legacy items without it fall back to `addedAt`. - Deletes record a tombstone `{ id, at }` in the top-level `deleted` array, so a delete is not resurrected by another device's stale copy. - On merge: union items by `id`; on a collision the newest `updatedAt` wins; a tombstone wins when `at` >= the item's stamp. Tombstones are unioned and pruned (180 days / 500 entries). - Writes only proceed against a just-read relay copy (`confirmed`); if the relay can't be reached the write is **refused** rather than queued, because a queued blob is published later as-is and could overwrite a newer edit. Reads still work offline from the cached ciphertext. `mergeStashes()` in `src/hooks/useStash.ts`. Both fields are optional, so older stashes (and other clients) remain readable. ### Item Types | Value | Description | |-------|-------------| | `stash` | Regular inventory item that the user owns | | `wtb` | Want To Buy - item the user is looking for | ### Visibility Levels | Value | Description | |-------|-------------| | `private` | Only visible to the owner. Encrypted in the kind 30001 blob. | | `friends` | Reserved for future friends-only sharing (currently treated as private). | | `public` | Published as a separate public event (see below) and shown in the owner's public Stash on their profile. | ### Public Stash Showcase Because the Stash (kind 30001) is a single NIP-44 encrypted blob, its contents are never readable by others. To make an item genuinely public, the client **also** publishes it as a separate, unencrypted **NIP-99 Classified Listing (kind 30402)** — the same kind used by the marketplace — with a `["t", "stash-showcase"]` tag. The public event's `d` tag is `stash-<itemId>`, mirroring the private item's stable id so it can be updated in place or removed via a NIP-09 deletion when the item goes private or is deleted. Two flavors (hybrid model): | Case | Marker tags | Where it appears | |------|-------------|------------------| | Public, **not** for sale | `["showcase", "true"]`, `["price", "0", "SATS"]` | Public Stash on the profile only (filtered out of the sellable marketplace) | | Public **and** for sale/trade | `["price", "<sats>", "SATS"]`, `["listing_type", "selling"]`, `["status", "active"]`, optional `["trade", "true"]` | Public Stash on the profile (badged "For Sale") **and** the marketplace | Clients rendering the marketplace MUST skip events carrying `["showcase", "true"]` so showcase-only items don't pollute buy/sell listings. Example public showcase event (not for sale): ```json { "kind": 30402, "content": "My daily driver deck.", "tags": [ ["d", "stash-a1b2c3d4"], ["title", "Baker Deck 8.25"], ["t", "skateboarding"], ["t", "stash-showcase"], ["t", "deck"], ["category", "deck"], ["condition", "good"], ["brand", "Baker"], ["image", "https://blossom.example/deck.jpg"], ["price", "0", "SATS"], ["showcase", "true"], ["status", "active"], ["alt", "Baker Deck 8.25 — public gear from a SkateSpots stash"] ] } ``` ### Categories Same as Gear Listings: `deck`, `trucks`, `wheels`, `bearings`, `shoes`, `clothing`, `protective`, `complete`, `other` ### Conditions | Value | Description | |-------|-------------| | `new` | Brand new, never used | | `like_new` | Barely used, excellent condition | | `good` | Normal wear, functions perfectly | | `well_used` | Shows wear but still works | ### Rarity (Optional) Items can have an optional rarity tier for RPG-style display: | Value | Description | |-------|-------------| | `common` | Standard items | | `uncommon` | Slightly rare brands/models | | `rare` | Hard to find items | | `epic` | Very rare or valuable items | | `legendary` | Extremely rare collector items | ### WTB Matching The app performs client-side matching between WTB items and stash items from friends (mutual follows). Matches are based on: - Category (required match) - Brand (fuzzy match) - Size (within tolerance) - Price compatibility (WTB maxPrice >= stash minPrice) When a match is found, the app can facilitate P2P trading via encrypted DMs (NIP-17). ### Privacy Model - All stash data is NIP-44 encrypted to self - Private items are never shared without explicit user action - "Friends" visibility items can be queried by mutual follows through a discovery protocol - WTB matching happens client-side with no central server ### Security Considerations 1. The stash is encrypted at rest using the user's Nostr keys 2. Selective disclosure allows users to share specific items via DM 3. No price or inventory data is publicly visible unless explicitly set to "public" 4. Group DMs (NIP-17) can be used for coordinating trades between multiple parties ## Rider Cards SkateSpots supports physical-print cards that are cryptographically signed on Nostr and verifiable by anyone. ### Park Access Card (kind 35679) Issued by a private skatepark owner to a specific rider, granting access rights. The park's Nostr key signs the event — anyone can verify the card is genuine by scanning the QR code. **Constraint:** Park Access Cards for private parks (`access=private` in kind 30818) should only be issued by the park's owner pubkey. Clients verify the issuer's pubkey matches the park event's `pubkey` field. ```json { "kind": 35679, "content": "", "tags": [ ["d", "<unique-id>"], ["p", "<rider-pubkey>"], ["t", "skatepark-access"], ["issuer", "<park name>"], ["access-level", "<Full Access|Supervised|Beginner Zone|Street Only|Bowl Only|Members Only>"], ["theme", "<thug|lime|chrome|wood|midnight|sunset>"], ["valid-thru", "<YYYY-MM-DD>"], ["a", "30818:<park-pubkey>:<park-d>"], ["alt", "Skatepark Access Card"] ] } ``` **Venue binding (`a`, optional but recommended):** when present, it points at the park's own spot (kind 30818). The `/verify` page fetches that spot and confirms its **author pubkey equals the pass's signer** — i.e. the pass was issued by the key that owns the venue. A pass with no `a` tag still verifies as a signature, but is shown as **not bound to a venue** (any key can mint one), so a park that cares about authenticity should always set it. ### Card Themes | Value | Description | |-------|-------------| | `thug` | Black + Neon Red (default) | | `lime` | Black + Neon Lime | | `chrome` | Steel & Silver | | `wood` | Woodgrain Classic | | `midnight` | Deep Blue | | `sunset` | Orange & Pink | ### Rider Card (kind 32271, addressable) The personal **Rider License** is published as an addressable event (kind 32271, `d = "rider-card"`) signed by the skater themselves. It persists across devices and can be linked to and verified. Name, avatar and NIP-05 still come from the author's kind 0; on-chain stats are derived live: - Spots added: count of kind 30818 authored by the user - Zaps received: count of kind 9735 targeting the user - Events created: count of kind 31923 authored by the user - Witnessed skill badges: count of kind 1293 with `#p` = user ```json { "kind": 32271, "content": "", "tags": [ ["d", "rider-card"], ["t", "rider-card"], ["theme", "<thug|lime|chrome|wood|midnight|sunset>"], ["v", "1"], ["style", "<Street|Park|Vert|Bowl|DIY|All-terrain>"], ["skill", "<skill-id>"], ["sponsor", "<text>"], ["rides-for", "<text>"], ["show", "spots"], ["show", "zaps"], ["show", "events"], ["alt", "SkateSpots Rider Card"] ] } ``` - `v:1` marks the `show` flags as explicit, so hiding a stat persists (an event with no `v`/`show` tags defaults all three to visible). - The card shows **self-claimed** skills (`skill` tags) in the theme colour and **witnessed** badges (kind 1293) in gold ⭐. ### Verification Both card types are verifiable. The QR on a printed card encodes the `naddr` of its event, which opens the in-app verify page: ``` URL format: https://skate-spots.org/#/verify/<naddr> Public card: https://skate-spots.org/#/card/<npub> ``` `/verify` fetches the event, runs `verifyEvent()` on the signature, and for a park pass also checks the venue binding (above). It is deliberately honest: a valid signature proves **who signed** the card — never skill, and never access rights beyond what the venue itself issued. ## Skill Badge Awards (kind 1293) Anyone who witnesses a rider performing a trick can award them a cryptographically signed skill badge. This creates an on-chain proof of skill — unfakeable because the witness's Nostr key signs the event. ### Design Rationale Skill badges are **regular events** (not addressable) because: - Multiple witnesses can each award the same skill to the same rider independently - Each badge is a distinct attestation with its own timestamp and witness - The full history of attestations is valuable (not just the latest) ### Event Format ```json { "kind": 1293, "content": "", "tags": [ ["p", "<rider-pubkey>"], ["skill", "<skill-id>"], ["skill", "<skill-id>"], ["spot", "<spot name (optional)>"], ["t", "skill-badge"], ["alt", "Skill badge awarded: <skill>, <skill>"] ] } ``` ### Required Tags | Tag | Description | |-----|-------------| | `p` | Pubkey of the rider receiving the badge | | `skill` | Skill identifier (one tag per skill, multiple allowed) | | `t` | Must be `skill-badge` for discoverability | | `alt` | NIP-31 human-readable description | ### Optional Tags | Tag | Description | |-----|-------------| | `spot` | Name of the spot where the trick was witnessed | | `a` | Reference to a kind 30818 spot event | ### Skill Identifiers | Value | Label | |-------|-------| | `kickflip` | Kickflip | | `heelflip` | Heelflip | | `switch` | Switch stance | | `nollie` | Nollie | | `fakie` | Fakie | | `grind` | Grinds | | `slide` | Slides | | `manual` | Manuals | | `vert` | Vert | | `bowl` | Bowl | | `street` | Street | | `park` | Park | | `film` | Filmer | | `diy` | DIY Builder | ### Trust Model The value of a badge is proportional to the reputation of the witness: - NIP-05 verified witnesses carry more weight - Witnesses known in the community (follows, mutual follows) carry more weight - The skill badge feed is public — anyone can query badges for any rider ### Querying **All badges received by a rider:** ```json { "kinds": [1293], "#p": ["<rider-pubkey>"], "#t": ["skill-badge"] } ``` **All badges issued by a witness:** ```json { "kinds": [1293], "authors": ["<witness-pubkey>"], "#t": ["skill-badge"] } ``` **Badges for a specific skill:** ```json { "kinds": [1293], "#p": ["<rider-pubkey>"], "#skill": ["kickflip"] } ``` ### Example ```json { "kind": 1293, "pubkey": "witness-pubkey-hex", "created_at": 1703001600, "content": "", "tags": [ ["p", "rider-pubkey-hex"], ["skill", "kickflip"], ["skill", "switch"], ["spot", "MACBA Barcelona"], ["a", "30818:abc123:macba"], ["t", "skill-badge"], ["alt", "Skill badge awarded: kickflip, switch"] ], "id": "...", "sig": "..." } ``` ## Skater Profile (kind 35000) The **Skater Profile** is the app-native identity that makes a Nostr pubkey a *discoverable SkateSpots skater*. It is an addressable event identified by `pubkey + kind + d-tag` (the `d` tag is always `skater-profile`, so a user has exactly one). **This is the anti-spam boundary for the `/skaters` directory.** A plain kind-0 metadata profile is shared by the entire Nostr network (every social client, every bot) and is therefore never sufficient to appear in the directory. Only events carrying kind `35000` **and** the `["t", "skateskater"]` marker tag — published from within the app, and queried exclusively from the SkateSpots community relay — are listed. This complements relay-level write restrictions: the relay keeps junk out at the source, the marker defines who is a skater. Unlike Lab provider profiles, a Skater Profile advertises no paid service — it exists purely for community discovery. ```json { "kind": 35000, "content": "<bio / skate story>", "tags": [ ["d", "skater-profile"], ["title", "<display name at publish time>"], ["t", "skateskater"], ["t", "skateboarding"], ["alt", "SkateSpots skater: <display name>"], ["stance", "regular | goofy"], ["level", "grom | intermediate | advanced | ripper"], ["level_custom", "<free-text level, ≤40 chars, mutually exclusive with `level`>"], ["t", "<style>"], ["years", "<years skating>"], ["home_spot", "<freeform home spot, or the title of the linked spot>"], ["a", "30818:<spot pubkey>:<spot d-tag> (optional — set when home_spot links a real mapped spot)"], ["location", "<city / area>"], ["g", "<geohash, coarse>"], ["instagram", "<@handle>"], ["website", "<url>"] ] } ``` Recognized `style` values (repeated `t` tags): `street`, `park`, `vert`, `transition`, `bowl`, `cruising`, `downhill`, `freestyle`, `ledges`, `stairs`, `diy`. A valid profile requires the `d` tag, a `title`, and the `skateskater` marker tag. Level is either one of the four presets via the `level` tag, **or** a free-text label via `level_custom` (max 40 chars) — clients should treat these as mutually exclusive and prefer `level` when both are present. The skater's avatar always comes from their kind-0 profile picture — the skater profile does not carry its own image, so the two never diverge. When `home_spot` refers to a real mapped spot, an `a` tag holds the `30818:pubkey:d` coordinate so clients can link straight to it. ## The Lab — Service Profiles The Lab connects skaters with service providers. Each provider type is an addressable event (kind 35001–35004) identified by `pubkey + kind + d-tag`. They share a common base schema and are distinguished by kind and a self-tag. | Kind | Provider | Self-tag | |------|----------|----------| | 35001 | Coach | `skatecoach` | | 35002 | Filmer (video) | `skatefilmer` | | 35003 | Video Editor | `skateeditor` | | 35004 | Photographer | `skatephotographer` | | 35005 | Builder (ramp/park/DIY) | `skatebuilder` | | 35007 | Designer / Artist | `skateartist` | | 35008 | Graffiti / Mural artist | `skategraffiti` | | 35009 | Tattoo artist | `skatetattoo` | | 35010 | Trainer / Physiotherapist | `skatetrainer` | | 35011 | Musician / Band | `skatemusician` | | 35012 | Event Judge | `skatejudge` | | 35013 | Event Organizer / Host | `skateorganizer` | | 35014 | Barber / Hairstylist | `skatebarber` | > Kind `35006` is **not** a Lab provider — it is the Skate Crew (see below). ### Common Base Schema ```json { "kind": 35004, "content": "<bio / about the provider>", "tags": [ ["d", "<profile-identifier>"], ["title", "<display name>"], ["t", "<self-tag>"], ["t", "<service/skill/gear tag>"], ["location", "<city / area>"], ["g", "<geohash>"], ["image", "<profile image url>"], ["experience", "<years>"], ["rate", "<freeform rate, e.g. 20000 sats/shoot>"], ["phone", "<phone>"], ["email", "<email>"], ["website", "<url>"], ["entity", "individual"], ["a", "30818:<pubkey>:<d-tag>"], ["alt", "<human-readable description>"] ] } ``` #### Required Tags | Tag | Description | |-----|-------------| | `d` | Unique profile identifier (e.g. `photography-profile`) | | `title` | Display name | | `t` | Must include the provider self-tag (see table above) | #### Shared Optional Tags `location`, `g` (geohash), `image`, `experience`, `rate`, `phone`, `email`, `website`, `noffer` (CLINK payment code — see below), `entity` (see Entity Type below), `p` (collective member pubkeys — see Collective Membership below), `a` (spot references to kind 30818), `alt`. ### Pro / Featured Listings (`featured` tag) Lab profiles, calendar events (kind 31923), and skate tours (kind 30823) support an optional **Pro / featured** upgrade. Basic listings are always free; paying the optional platform fee adds a `featured` tag whose value is the **unix-seconds expiry** of the featured window: ```json ["featured", "1767225600"] ``` Clients that see an unexpired `featured` timestamp render a gold **"Pro"** badge and sort the listing ahead of non-featured ones. Once the timestamp passes, the listing simply reverts to a normal free listing (no badge, no boost) — it is never unpublished. #### Duration tiers The featured window is purchased at one of four duration tiers, with a per-day discount that grows with the commitment length (price discrimination). The base is a 30-day monthly price per listing type; the charged amount is `base × multiplier`: | Tier | Days | Multiplier | Note | |------|------|-----------|------| | 7 days | 7 | 0.4× | short "try it" window | | 1 month | 30 | 1.0× | baseline | | 3 months | 90 | 2.4× | best value (~20%/day off) | | 12 months | 365 | 8.0× | biggest discount (~33%/day off) | The chosen tier only determines the computed expiry written into the `featured` tag — the tag schema itself is unchanged. #### Verification via NIP-57 zap receipts (layered / best-effort) To make Pro tamper-proof where possible, payment is attempted as a **NIP-57 zap** rather than a plain LNURL-pay. The client signs a kind `9734` zap request tagging the platform pubkey (`p`) and, when known, the featured entity's addressable coordinate (`a` = `kind:pubkey:d`), and passes it to the platform Lightning address's LNURL callback. If the provider is zap-enabled (`allowsNostr` + `nostrPubkey`), it publishes a kind `9735` zap receipt. Clients verify a `featured` listing by querying for a matching kind `9735` receipt (`#a` = entity coordinate, `#p` = platform pubkey). Three states result: | State | Meaning | Badge | |-------|---------|-------| | `verified` | A matching zap receipt was found. Cryptographically backed. | Green ✓ Pro | | `claimed` | Unexpired `featured` tag but no receipt found (provider doesn't publish receipts, or relays lacked it). Self-attested. | Gold Pro | | `none` | Not featured / window expired. | (none) | > **Trust note:** the fallback is **graceful** — if the platform's Lightning > provider does not publish zap receipts (as with some custodial/ecash > addresses), verification degrades to the older *self-attested* `claimed` > level and Pro never silently breaks. When receipts are available the badge > becomes genuinely verifiable. For events and tours whose `d` identifier is > generated at publish time (non-deterministic), the zap request omits the `a` > coordinate, so those fall back to `claimed`. ### Entity Type (individual vs. collective) Every Lab profile carries an optional `entity` tag declaring whether it represents a single person or a group: | Tag | Value | Meaning | |-----|-------|---------| | `entity` | `individual` | A single person (default if omitted). | | `entity` | `collective` | A multi-person entity: crew, studio, or company. | "Company" is presented as cosmetic sub-styling of a collective, **not** a distinct trust tier — on Nostr a company and a collective share the same trust model (a shared brand backed by verifiable member keys). ```json ["entity", "collective"] ``` ### Collective Membership (NIP-32 attestations) A collective profile lists its claimed members via `p` tags (member pubkeys in hex). Because the collective's founder key alone signs the profile, a bare `p` tag is only a **claim** — it cannot prove the listed person consented. To make membership **unforgeable**, each member independently publishes a [NIP-32](https://github.com/nostr-protocol/nips/blob/master/32.md) label event (kind `1985`) signed with their **own** key, referencing the collective's addressable coordinate: ```json { "kind": 1985, "content": "I'm a member of Barcelona Filmers", "tags": [ ["L", "org.skatespots.membership"], ["l", "member", "org.skatespots.membership"], ["a", "35002:<founderPubkey>:<d-tag>"], ["p", "<founderPubkey>"], ["alt", "Skate collective membership confirmation for Barcelona Filmers"] ] } ``` **Client logic:** cross-reference the collective's `p` tags with member attestations. A member is: - **Confirmed** — listed by the collective **AND** has published a matching membership label. Rendered with a green "Confirmed" chip. - **Pending** — listed by the collective but has not attested. Amber "Pending" chip. - A stray attestation from someone the collective does **not** list is ignored (prevents unsolicited membership claims). Removing a member: the founder drops the `p` tag, or the member deletes their label event (NIP-09). Either direction flips the member out of "confirmed". #### Member visibility (opt-in privacy) A member controls their own discovery visibility **on their own attestation** (not the founder's event), via an optional `visibility` tag: ```json ["visibility", "public" | "crew"] ``` | Value | Meaning | |-------|---------| | `public` *(default, tag absent)* | Listed everywhere — public crew page, search, discovery. | | `crew` | Hidden from non-members. To outsiders the member renders as an anonymous "hidden member" slot (`+N hidden`); to the founder and confirmed crewmates they render normally. | This is **discovery privacy** at the app layer, controlled by the member's own key — so a public crew can contain a fully-hidden member. Note that the `p` tag on the founder's event is public regardless; `visibility` governs how clients *display* the member, not whether the pubkey exists on relays. Clients determine "is the viewer a crewmate?" by checking whether the viewer is the founder or a confirmed member before revealing `crew`-scoped members. ### Scene vouches (NIP-32 web-of-trust anti-fraud) Any user can **vouch** for a Lab profile — an independent, signed attestation that the provider is legit. This is a lightweight web-of-trust layer to fight impersonation and fraud without gatekeeping. A vouch is a kind `1985` label signed with the voucher's own key, referencing the provider's coordinate: ```json { "kind": 1985, "content": "Filmed a full part with them, 100% legit.", "tags": [ ["L", "org.skatespots.vouch"], ["l", "vouch", "org.skatespots.vouch"], ["a", "35011:<providerPubkey>:<d-tag>"], ["p", "<providerPubkey>"], ["alt", "Scene vouch for a SkateSpots Lab provider"] ] } ``` Clients aggregate vouches for a coordinate and **trust-weight** them using each voucher's web-of-trust score, so a handful of vouches from trusted skaters outweighs many from throwaway keys. A provider cannot vouch for themselves (self-vouches are ignored). Vouches are revocable via NIP-09 deletion. ### Photographer (kind 35004) Skate photography is a distinct craft from filming — it is shot-, light-, and print-oriented. A photographer profile records services and gear as queryable `t` tags, plus a freeform `gear` tag and a `portfolio`/`social` link. Categories encoded as `t` tags: - **Services**: `action`, `sequence`, `portraits`, `editorial`, `product`, `events`, `lifestyle`, `prints` - **Camera bodies**: `dslr`, `mirrorless`, `medium`, `film35`, `filmmedium`, `point`, `phone` - **Lenses**: `fisheye`, `wide`, `standard`, `telephoto`, `macro`, `zoom` - **Lighting**: `strobes`, `speedlights`, `profoto`, `elinchrom`, `godox`, `triggers`, `continuous`, `reflectors`, `naturallight` - **Support**: `tripod`, `monopod`, `lightstands`, `gimbal`, `bag`, `laptop` - **Style**: `flashfisheye`, `natural`, `bw`, `film`, `longlens`, `studio`, `street` Photographer-specific optional tags: | Tag | Description | |-----|-------------| | `gear` | Freeform "what's in the bag" (exact bodies, lenses, lights) | | `portfolio` | Portfolio link (Instagram, personal site, etc.) | | `social` | Additional social handle or link | #### Example: Photographer ```json { "kind": 35004, "content": "Shooting street and park sessions for 8 years. Love a lit flash fisheye at dusk.", "tags": [ ["d", "photography-profile"], ["title", "Ana Flash"], ["t", "skatephotographer"], ["t", "action"], ["t", "sequence"], ["t", "editorial"], ["t", "mirrorless"], ["t", "fisheye"], ["t", "telephoto"], ["t", "godox"], ["t", "triggers"], ["t", "tripod"], ["t", "lightstands"], ["t", "flashfisheye"], ["t", "bw"], ["location", "Barcelona, ES"], ["g", "sp3e9"], ["experience", "8"], ["rate", "25000 sats/shoot"], ["gear", "Sony A7 IV, 15mm fisheye, 70-200mm f/2.8, 2× Godox AD200 Pro, remote triggers, Manfrotto tripod"], ["portfolio", "https://instagram.com/anaflash"], ["alt", "Skate photographer: Ana Flash"] ] } ``` ### Builder (kind 35005) Skate ramp / park / DIY builders. Most builders are crews or companies, so this category defaults to `entity: collective`. Build types, materials and scale are stored as queryable `t` tags, plus builder-specific optional tags. Categories encoded as `t` tags: - **What they build**: `diy`, `concrete`, `wood`, `bowls`, `transitions`, `rails`, `boxes`, `minis`, `pumptrack`, `foam`, `repairs`, `design` - **Materials / trade**: `concretework`, `welding`, `carpentry`, `formwork`, `masonry`, `coping`, `surfacing`, `earthworks` - **Project scale**: `backyard`, `diycommunity`, `publicpark`, `indoor`, `event` Builder-specific optional tags: | Tag | Description | |-----|-------------| | `gear` | Freeform "how we work / equipment" | | `service_area` | How far they travel (freeform) | | `insured` | `"true"` if licensed/insured (relevant for public park work) | | `portfolio` | Link to past builds (Instagram, site) | | `social` | Additional social handle or link | The `a` (spot reference) tags are especially meaningful for builders — they link to the actual spots/parks (kind 30818) the builder has made, surfacing their work on the map. #### Example: Builder (collective) ```json { "kind": 35005, "content": "Concrete crew out of Malmö. 12 years pouring parks and DIY across the EU.", "tags": [ ["d", "builder-profile"], ["title", "Betong Bros"], ["t", "skatebuilder"], ["t", "concrete"], ["t", "bowls"], ["t", "transitions"], ["t", "repairs"], ["t", "concretework"], ["t", "formwork"], ["t", "coping"], ["t", "publicpark"], ["t", "diycommunity"], ["location", "Malmö, SE"], ["g", "u3bu5"], ["experience", "12"], ["rate", "per m² / by project"], ["service_area", "Scandinavia + will travel EU"], ["insured", "true"], ["gear", "Own mixer + shotcrete rig, mobile welding, handle drainage & permits"], ["portfolio", "https://instagram.com/betongbros"], ["entity", "collective"], ["p", "<member1-pubkey>"], ["p", "<member2-pubkey>"], ["a", "30818:<pubkey>:<d-tag>"], ["alt", "Skate ramp/park builder: Betong Bros"] ] } ``` ### Designer / Artist (kind 35007) Skate designers and artists — board graphics, logos, posters, merch, zines and illustration. Uses the common base schema with `t`-tag categories. Categories encoded as `t` tags: - **What they make**: `boardart`, `logos`, `posters`, `merch`, `zines`, `illustration`, `stickers`, `webdesign`, `typography`, `animation` - **Medium**: `digital`, `handdrawn`, `screenprint`, `paint`, `collage`, `photo`, `3d` - **Software / tools**: `illustrator`, `photoshop`, `procreate`, `affinity`, `figma`, `blender`, `aftereffects`, `clipstudio` Optional tags: `portfolio`, `social` (shared base). ### Graffiti / Mural Artist (kind 35008) Graffiti writers and muralists. Uses the common base schema. Categories encoded as `t` tags: - **Style**: `throwups`, `pieces`, `murals`, `characters`, `lettering`, `stencil`, `handstyles`, `3dstyle`, `abstract` - **Medium**: `spray`, `rollers`, `markers`, `brush`, `digital` - **Work context**: `legalwalls`, `commissions`, `liveart`, `skateparks`, `interiors`, `workshops` Graffiti-specific optional tags: | Tag | Description | |-----|-------------| | `crew` | Freeform **graffiti** crew name (distinct from a SkateSpots skate crew) | | `a` | Walls/spots painted — references to kind `30818` skate spots | ### Tattoo Artist (kind 35009) Skate-culture tattoo artists. Uses the common base schema. Categories encoded as `t` tags: - **Styles**: `traditional`, `neotraditional`, `fineline`, `blackwork`, `realism`, `lettering`, `illustrative`, `skateflash`, `ignorant`, `japanese` - **Method**: `machine`, `handpoke`, `flash`, `custom`, `coverup` Tattoo-specific optional tags: | Tag | Description | |-----|-------------| | `studio` | Studio / shop name | | `books_open` | `"true"` if currently taking bookings | ### Trainer / Physiotherapist (kind 35010) Physical trainers, physiotherapists and sports therapists who work with skaters. Uses the common base schema. Categories encoded as `t` tags: - **Focus**: `strength`, `mobility`, `injuryprevention`, `rehab`, `recovery`, `balance`, `cardio`, `nutrition` - **Injuries treated**: `ankles`, `knees`, `wrists`, `hips`, `back`, `shoulders`, `heelbruise`, `concussion` - **Credentials / setting**: `physio`, `osteo`, `certtrainer`, `sportstherapist`, `massage`, `inperson`, `remote` Trainer-specific optional tags: | Tag | Description | |-----|-------------| | `credentials` | Freeform certifications (e.g. "BSc Physiotherapy, CSCS") | | `remote` | `"true"` if remote / online coaching is offered | ### Musician / Band (kind 35011) Skaters who make music — solo artists, bands, DJs and producers. Skate scenes and local music are tightly linked (demos, jams, after-parties), so musicians are a first-class Lab craft. A skate **crew** may also carry `band`/`band-url` tags (see Crews) for a lighter-weight association; a full musician profile is the first-class, bookable identity. Uses the common base schema. Categories encoded as `t` tags: - **Genre**: `punk`, `hardcore`, `metal`, `rock`, `indie`, `hiphop`, `electronic`, `lofi`, `reggae`, `ska`, `jazz`, `funk`, `flamenco`, `folk`, `experimental`, `grunge` - **Instruments / role**: `vocals`, `guitar`, `bass`, `drums`, `keys`, `dj`, `producer`, `sax`, `strings`, `other` Musician-specific optional tags: | Tag | Description | |-----|-------------| | `act` | Act type: `solo`, `band`, `dj`, `producer`, `collective` | | `spotify` | Spotify link (`http(s)` only) | | `bandcamp` | Bandcamp link (`http(s)` only) | | `soundcloud` | SoundCloud link (`http(s)` only) | | `youtube` | YouTube link (`http(s)` only) | A band can be a `collective` with member `p` tags (bilateral NIP-32 attestation, same as other collectives), and may link to its skate crew via an `a` tag (`35006:pubkey:d`). ### Event Judge (kind 35012) Officials who judge skate events — from flat-ground Game of S.K.A.T.E. and best-trick jams to full contests, am-series qualifiers, and touring stops (e.g. a World Destruction Tour). A judge lists the formats they run, their judging specialty, and the level/circuit they have worked so organizers can book credible, fair officiating. Uses the common base schema. Categories encoded as `t` tags: - **Event formats**: `game_of_skate`, `best_trick`, `jam`, `contest`, `street_league`, `bowl`, `mini_ramp`, `park`, `street`, `downhill`, `freestyle`, `world_tour`, `am_series`, `local_jam`, `online` - **Specialty**: `head_judge`, `scoring`, `mc`, `format_design`, `rules`, `brackets`, `livestream`, `am_scout` - **Level / circuit**: `local`, `regional`, `national`, `international`, `pro` Judge-specific optional tags: | Tag | Description | |-----|-------------| | `events_judged` | Freeform note on notable events / comps / tours officiated (≤ base limits). | A judging panel can be a `collective` with member `p` tags, and may link to its skate crew via an `a` tag (`35006:pubkey:d`). ### Event Organizer (kind 35013) Skaters who put on events — street jams, best-trick comps, Game of S.K.A.T.E., meetups, demos, clinics and tour stops. Organizers list the event types they run, what they take care of (permits, sponsorship, sound, safety…) and the scale they work at, so skaters and brands can find and book them. Uses the common base schema. Categories encoded as `t` tags: - **Event types**: `street_jam`, `best_trick`, `game_of_skate`, `contest`, `bowl`, `mini_ramp`, `park`, `demo`, `clinic`, `meetup`, `downhill`, `slalom`, `kids`, `charity`, `tour_stop`, `premiere`, `market` - **What they handle**: `organizing`, `hosting`, `permits`, `sponsorship`, `promotion`, `livestream`, `sound`, `ramps`, `safety`, `vendors`, `media`, `registration` - **Scale**: `grassroots`, `local`, `regional`, `national`, `international`, `pro` Organizer-specific optional tags: | Tag | Description | |-----|-------------| | `events_hosted` | Freeform note on notable events / comps / meetups run (≤ base limits). | An organizing team can be a `collective` with member `p` tags, and may link to its skate crew via an `a` tag (`35006:pubkey:d`). ### Barber / Hairstylist (kind 35014) Barbers and hairstylists who cut for the skate scene — shop chairs, mobile / house calls, and cuts at events or comps. They list the services they offer, their signature styles, and where they work, so skaters can find a fresh trim. Uses the common base schema. Categories encoded as `t` tags: - **Services**: `haircut`, `fade`, `beard`, `lineup`, `colour`, `kids` - **Signature styles**: `skin_fade`, `taper`, `buzz`, `mohawk`, `mullet`, `curly`, `long`, `classic` - **Setting**: `shop`, `mobile`, `event` Barber-specific optional tags: | Tag | Description | |-----|-------------| | `studio` | Shop / studio name (≤ `MAX_LOCATION_LEN`). | | `books_open` | `"true"` when currently taking bookings. | A shop or collective can be a `collective` with member `p` tags, and may link to its skate crew via an `a` tag (`35006:pubkey:d`). ### Querying Lab Profiles ```json { "kinds": [35001, 35002, 35003, 35004, 35005, 35007, 35008, 35009, 35010, 35011, 35012, 35013, 35014], "#t": ["skatecoach", "skatefilmer", "skateeditor", "skatephotographer", "skatebuilder", "skateartist", "skategraffiti", "skatetattoo", "skatetrainer", "skatemusician", "skatejudge", "skateorganizer", "skatebarber"], "limit": 100 } ``` ## Skate Crews (kind 35006) A **Crew** is a map-anchored collective identity — the crew you skate/roll with, not a service you hire. It answers *"who skates here?"* rather than *"who can I hire?"* (that's The Lab). Crews are addressable events identified by `pubkey + kind + d-tag`. A crew is structurally a **collective** (it reuses the exact NIP-32 membership attestation mechanism defined in [The Lab § Collective Membership](#collective-membership-nip-32-attestations)) minus the service/rate fields, plus a **home turf** (map anchor) and optional **home spots**. Members are always attested per-member with their own key — a bare `p` tag on the crew is only a claim until the member self-labels. ### Event Format ```json { "kind": 35006, "content": "Late-night ledge crew. We haunt the plaza. All welcome, no kooks.", "tags": [ ["d", "barcelona-night-crew"], ["title", "Barcelona Night Crew"], ["t", "skatecrew"], ["image", "https://blossom.example/crew-logo.png"], ["g", "sp3e"], ["location", "Barcelona, Spain"], ["founded", "2019"], ["t", "street"], ["t", "transition"], ["t", "crew-focus-photo"], ["p", "<memberPubkey1>", "", "admin"], ["p", "<memberPubkey2>"], ["crew-role", "<memberPubkey2>", "filmer"], ["crew-role", "<founder>", "og"], ["a", "30818:<spotFounder>:<spotDtag>"], ["nip05", "barcelonanightcrew@bcnnightcrew.example"], ["website", "https://bcnnightcrew.example"], ["video", "https://www.youtube.com/watch?v=dQw4w9WgXcQ"], ["sponsor", "30017:<shopFounder>:<shopDtag>"], ["coordinator", "https://coordinator.bcnnightcrew.example"], ["noffer", "noffer1qqs..."], ["alt", "Skate crew: Barcelona Night Crew"] ] } ``` ### Required Tags | Tag | Description | |-----|-------------| | `d` | Unique crew identifier | | `title` | Crew name | | `t: skatecrew` | Self-tag for discovery/querying | ### Optional Tags | Tag | Description | |-----|-------------| | `image` | Crew logo / photo | | `g` | Geohash of the crew's **home turf** (map anchor) | | `location` | Human-readable home location | | `founded` | Year founded (cosmetic) | | `t: <vibe>` | Style/focus tags. Preset vibes plus any user-defined custom hashtag (lowercased, hyphenated slug). Examples: `street`, `transition`, `park`, `diy`, `oldschool`, `allterrain`, `cruising`, `girls`, `youth`, `adaptive`, `downhill`, `night-sessions`, etc. | | `t: crew-focus-<scene>` | Optional **Lab scene** for the crew, surfacing it inside The Lab's matching category. One of `crew-focus-builder` (DIY & Builders), `crew-focus-photo` (Photo & Film — filming, photography & editing), `crew-focus-coaching` (Coaching), `crew-focus-art` (Art & Design — designers/artists, graffiti & tattoo), `crew-focus-music` (Band & Music — musicians), or `crew-focus-wellness` (Training & Physio — trainers). Absent = a "Mixed / General" crew that only appears on the Crews page. Queryable at the relay level via `#t`. | | `t: crew-agent` | Optional marker that the crew has enabled its **AI Crew Agent** ("🐝 Sasquatch"). When present, clients may derive the crew's agent identity (see below) and expose an "Ask Sasquatch" experience for members. Absent = no agent. | | `p` | Member pubkeys (hex). Confirmed only when the member self-attests (see below). An optional 4th element `"admin"` marks the member as a delegated admin (see Governance). | | `a` | Home spots the crew claims/haunts — references to kind `30818` skate spots (up to 8). Cross-links the crew to the map. | | `nip05` | Optional crew NIP-05 identifier (`local@domain`). Verified against the **founder** pubkey — see NIP-05 Verification. | | `website`, `instagram`, `social` | Links | | `band` | Optional name of the crew's **band / music project**. Skate scenes and local music are tightly linked, so a crew may double as a band. Freeform text (≤100 chars). For a full band profile (genres, instruments, gigs) a crew should instead create a **Musician** Lab profile (kind `35011`), set its focus to `crew-focus-music`, and cross-link the profile via an `a` tag. | | `band-url` | Optional link to the crew band's music (Spotify, Bandcamp, YouTube, a Nostr music profile, etc.). Must be an `http(s)` URL. Only meaningful alongside `band`. | | `noffer` | Optional shared tip jar (CLINK Offer — see below) | | `goal` | Optional **crew treasury** — the event id of a NIP-75 Zap Goal (kind `9041`) the crew is raising funds for. See Crew Treasury below. | | `looking-for` | Optional **open call** — a Lab craft the crew is hiring for. Repeatable. Slugs: `filmer`, `photographer`, `editor`, `coach`, `builder`, `artist`, `musician`, `members`. Surfaces the crew's needs so matching Lab providers can reach out. | | `sponsor` | Optional **sponsoring shop** — a NIP-15 stall coordinate (`30017:pubkey:d`) for a local skate shop that backs the crew. Renders a "Sponsored by [Shop]" banner linking to the shop. Only shown when the shop resolves. | | `coordinator` | Optional, **advanced/self-host**: routes the crew's private chat through a founder-run **Cordn** coordinator instead of the built-in NIP-17 chat. Shape: `["coordinator", <url>, <coordinatorPubkey?>, <operatorPubkey?>]`. `<url>` is an `http(s)`/`wss` endpoint of a Cordn coordinator (a Delivery Service reached over ContextVM). The crew event stays public and discoverable; message content and keys live client-side; nothing crew-specific is published to public relays. `<coordinatorPubkey>` is the coordinator service pubkey (hex) for ContextVM addressing; `<operatorPubkey>` is the human (hex) who runs it, if delegated. Not required — every crew has E2E chat without it. Clients that accept a pasted pointer **MUST** also accept the formats a coordinator run from Cordn's own app yields: an `nprofile`, or a `https://cordn.net/chat/coordinators?c=<nprofile>` link — deriving `<url>` from the nprofile's relay hints (falling back to the ContextVM relay) and `<coordinatorPubkey>` from its pubkey. A plain `http(s)`/`wss` endpoint remains valid. | | `video` | Optional **crew video / montage** ("The Part") — a **YouTube** or **Instagram** URL, rendered as the crew's headline embed. Only these two sources are accepted (skate parts live on YouTube, clips on Instagram); any other URL is ignored. No native/self-hosted video. Clients must parse it to a known embed (YouTube video id / Instagram shortcode) and reject anything else rather than render an untrusted iframe. | | `crew-role` | Optional **skate role** assigned to a roster member. Shape: `["crew-role", <memberPubkey>, <role>]`, repeatable (a member may hold several). `<role>` ∈ the full Lab-craft set — `filmer`, `photographer`, `editor`, `coach`, `builder`, `artist`, `graffiti`, `tattoo`, `trainer`, `musician`, `judge` — plus the crew-specific `manager` and `og`. The Lab-craft roles mirror the Lab profession kinds (35001–35012), so a crew's "filmer" speaks the same language as a Lab Filmer profile. These are the *functions* on a skate team (the filmer is as vital as the skaters) and are purely a **flex/label** — they grant **no powers** (only the `admin` marker does). Only honoured for the founder or a listed member; roles on non-roster pubkeys are ignored. | ### Crew-Hosted Events A crew can host calendar events (jams, comps, meetups). A crew-hosted event is an ordinary NIP-52 time-based calendar event (kind `31923`, `t: skateboarding`) that carries an `a` tag pointing back at the crew coordinate, plus a readable `crew-name` tag: ```json { "kind": 31923, "tags": [ ["d", "bcn-night-jam-2025"], ["title", "BCN Night Jam"], ["start", "1730000000"], ["t", "skateboarding"], ["a", "35006:<founder>:<crewDtag>"], ["crew-name", "Barcelona Night Crew"] ] } ``` Clients query a crew's events with `{ kinds: [31923], "#a": ["35006:<founder>:<crewDtag>"] }` and display "Hosted by [crew]" on the event, closing the Crews ↔ Events ↔ Zaps loop. ### Events ↔ Musicians (live music) An event may feature a **musician** (Lab kind `35011`) — e.g. a band playing a jam or comp afterparty. This reuses the exact `a`-tag pattern as crew hosting: the event carries an `a` tag pointing at the musician's coordinate, plus a readable `music-act` tag. ```json { "kind": 31923, "tags": [ ["d", "bcn-jam-afterparty"], ["title", "BCN Jam Afterparty"], ["start", "1730000000"], ["t", "skateboarding"], ["a", "35011:<musicianPubkey>:<musicianDtag>"], ["music-act", "The Curb Grinders"] ] } ``` The event page shows a "🎵 Live: [act]" pill. Reciprocally, a musician's Lab profile queries `{ kinds: [31923], "#a": ["35011:<pubkey>:<dtag>"] }` to list its **upcoming gigs**, closing the Music ↔ Events loop. An event may carry both a crew `a` tag and a musician `a` tag. ### Crew Treasury (NIP-75 Zap Goal) A crew may run a transparent, **non-custodial** funding goal for a shared cause (fixing a DIY spot, a crew trip, a comp prize pot). The founder publishes a [NIP-75](https://github.com/nostr-protocol/nips/blob/master/75.md) Zap Goal (kind `9041`) with an `amount` (millisats target) and links it from the crew via a `["goal", "<goalEventId>"]` tag. Progress is tallied client-side from zap receipts (kind `9735`) that reference the goal event id. Contributions are ordinary zaps that settle to the goal author's (founder's) own Lightning address — **SkateSpots never custodies funds**; it only reads the public tally. No shared wallet or new wallet settings are required; the payment rail is the founder's existing Lightning address / CLINK noffer, and contributors pay with their in-app wallet (WebLN / NWC / Cashu). ### Membership Crews reuse the collective membership model verbatim. A member confirms belonging by publishing a NIP-32 label (kind `1985`) referencing the crew's `a`-coordinate, `["l", "member", "org.skatespots.membership"]`. Confirmed / pending chips and removal semantics are identical to Lab collectives. ### Governance (founder + delegated admins) A crew's identity is a single **addressable event authored by the founder**. On Nostr, an addressable event can only be overwritten by its author's key, so the founder's pubkey is the crew's single source of truth — **conflicts are impossible at the data layer** (a fork by anyone else is a *different* event under a *different* author and is ignored by clients querying the founder's coordinate). Roles: | Role | Determined by | Powers | |------|---------------|--------| | **Founder** | Authored the crew event | Sole owner of the crew identity. Only role that can **edit the profile** (name, logo, members, admins, NIP-05, etc.). | | **Admin** | A confirmed member whose `p` tag carries the `"admin"` marker | Delegated *powers* exercised with the admin's **own key**: invite/vouch members, moderate the crew feed & chat, represent the crew. Cannot rewrite the profile. | | **Member** | Listed `p` tag + self-attested | Appears in the crew, posts to the feed/chat. | Admins are always a **subset of members** — a pubkey marked `admin` that is not also a confirmed member is treated as a plain (pending) member until it attests. > **Why not egalitarian shared editing?** Letting arbitrary members overwrite > the crew profile would require either a **shared private key** (a single leak > hijacks the crew) or a **consensus/multisig layer** (heavy and > non-interoperable). SkateSpots deliberately uses the owner + delegated-mods > model (like GitHub orgs, Discord, and NIP-72 communities): the founder owns > the record, admins wield delegated powers with their own keys. #### Moderation (NIP-32 `hidden` labels) Moderators (founder + admins) can hide an item from the **crew feed** *or* the **crew chat** by publishing a kind `1985` label with their **own key**: ```json { "kind": 1985, "tags": [ ["L", "org.skatespots.moderation"], ["l", "hidden", "org.skatespots.moderation"], ["e", "<hiddenItemId>"], ["a", "35006:<founder>:<crewDtag>"], ["p", "<itemAuthor>"], ["alt", "Crew moderation: post hidden"] ] } ``` - **Feed** (public kind-1 notes): `e` is the note id. Hiding removes it from *this crew's feed only*; the note remains on Nostr. - **Chat** (NIP-17 E2EE group DMs): `e` is the rumor (kind-14) message id. Because chat is end-to-end encrypted, messages **cannot be deleted** from relays — moderation is enforced **client-side**: every member's client hides a message carried by a valid moderator label. The label is public and reveals only that *a* message id was hidden, never its content. A client honors a label only when its **author is the crew founder or a listed admin**, so outsiders can't forge moderation. Moderators still see hidden items (greyed, marked "Hidden") so the action is transparent and reversible. #### Invites (kind `36229`) — structured, notifiable Because only the founder can write the crew's `p` (member) list, an admin cannot silently add members. A moderator (founder or admin) can send a **structured invite**: an addressable event (kind `36229`) that `p`-tags a specific invitee so that person can query "invites addressed to me" and be **notified**, then decide Accept or Decline. The invite carries nothing sensitive — real membership stays unforgeable (founder `p` tag + invitee attestation). ```json { "kind": 36229, "content": "Come skate with us — the ledge crew wants you.", "tags": [ ["d", "35006:<founder>:<crewDtag>:<inviteePubkey>"], ["p", "<inviteePubkey>"], ["a", "35006:<founder>:<crewDtag>"], ["crew_name", "Barcelona Night Crew"], ["role", "member"], ["alt", "Crew invite: join Barcelona Night Crew on SkateSpots"] ] } ``` | Tag | Description | |-----|-------------| | `d` | Deterministic id `<crewCoord>:<inviteePubkey>` — re-inviting the same person **replaces** the prior invite (addressable). | | `p` | The invitee's pubkey (hex). Queryable at the relay level so the recipient can find invites addressed to them: `{ kinds: [36229], "#p": ["<me>"] }`. | | `a` | The crew coordinate (`35006:pubkey:d`) being invited to. | | `crew_name` | Readable crew name for rendering the notification without a second fetch. | | `role` | `member` (default) or `admin` — the role proposed for the invitee. | **Flow:** 1. **Send.** A moderator publishes the invite. When the sender is the **founder**, they *optimistically pre-list* the invitee's `p` tag on the crew event, so the moment the invitee accepts (attests) they are a **confirmed** member with no extra founder step. An **admin** can send the invite but cannot edit the founder's event; the founder confirms the add on accept. 2. **Notify.** The invitee's client queries `#p` invites and surfaces a notification with **Accept** / **Decline**. 3. **Accept.** The invitee publishes their own NIP-32 membership attestation (kind `1985`, as above). Since the invitee can't delete an invite they didn't author, the invite is cleared from *their* notifications via the synced dismissal set (below). 4. **Decline.** Dismissal only — no public event required. Founders also retain a shareable **link/message** invite for people not yet on Nostr, and a shortcut to add a member key directly in crew settings. #### Join requests (kind `31969`) — the symmetric, notifiable inverse of invites An invite flows moderator → skater. A **join request** flows the other way: a skater asks a crew to let them in. To keep the two directions symmetric — i.e. so a request lands in the leadership's **notification bell** with a one-click action, rather than getting lost in a DM inbox — a request is a public, addressable event (kind `31969`) that `p`-tags the founder **and** every admin. It carries nothing sensitive: just an optional note. ```json { "kind": 31969, "content": "Local, skate the plaza most nights — would love to roll with you.", "tags": [ ["d", "35006:<founder>:<crewDtag>:<requesterPubkey>"], ["a", "35006:<founder>:<crewDtag>"], ["crew_name", "Barcelona Night Crew"], ["p", "<founderPubkey>"], ["p", "<adminPubkey>"], ["alt", "Crew join request: Barcelona Night Crew on SkateSpots"] ] } ``` | Tag | Description | |-----|-------------| | `d` | Deterministic id `<crewCoord>:<requesterPubkey>` — re-requesting **replaces** the prior request (addressable), so a crew never accrues duplicate asks from the same person. | | `a` | The crew coordinate (`35006:pubkey:d`) being requested. | | `crew_name` | Readable crew name for rendering the notification without a second fetch. | | `p` | One entry per **founder + admin**. Queryable at the relay level so each moderator finds requests addressed to them: `{ kinds: [31969], "#p": ["<me>"] }`. | **Flow:** 1. **Request.** A logged-out-of-the-crew skater publishes the request. The event is authored by the requester (`pubkey`), so the "who wants in" claim is cryptographically bound to them — no spoofing. 2. **Notify.** Each moderator's client queries `#p` requests, filters to crews they actually lead/help run, and surfaces a card with **Add to crew** (founder only) / **Dismiss**. 3. **Approve.** Only the **founder** can add the requester's `p` tag to the crew event (they author it); the requester then confirms with their own NIP-32 attestation, exactly as with an invite. 4. **Endorse (admins).** An admin can't edit the roster, but can publicly **vouch** for a request (kind `9087`, below). The founder sees the endorsement(s) on the request card as social proof and ratifies with one click — closing the admin/founder asymmetry without changing who owns the crew identity. 5. **Dismiss.** Because a moderator can't delete an event they didn't author, handled/ignored requests are cleared from *their* bell via the synced dismissal set (below). #### Admin endorsements (kind `9087`) — propose, founder confirms Because the founder is the sole author of the crew event, an admin approving a member directly is impossible by design. Instead an admin publishes a small **endorsement** — a public "I vouch for this rider" signal that `e`-tags the join-request, `a`-tags the crew, and `p`-tags both the requester (role `requester`) and the founder (so it lands in the founder's bell). ```json { "kind": 9087, "content": "Solid — skates the plaza with us most nights.", "tags": [ ["e", "<joinRequestEventId>"], ["a", "35006:<founder>:<crewDtag>"], ["p", "<requesterPubkey>", "", "requester"], ["p", "<founderPubkey>"], ["alt", "Crew endorsement: vouching for a new member of Barcelona Night Crew"] ] } ``` | Tag | Description | |-----|-------------| | `e` | The join-request (kind `31969`) event id being endorsed. Queryable so a client fetches all endorsements for a set of requests in one `#e` filter. | | `a` | The crew coordinate — used to verify the endorser is actually an **admin of that crew** (endorsements from non-admins are ignored, so nobody can inflate a request's credibility). | | `p` (requester) | The rider being vouched for, marked with the `requester` role. | | `p` (founder) | Surfaces the endorsement in the founder's notification bell. | Clients **must** keep only endorsements whose author is a listed admin of the endorsed crew, and dedupe to one endorsement per admin per request. #### Collective join requests (kind `36387`) — join a Lab collective Lab collectives (studios, build teams, filmer houses — any Lab profile with `entity = collective`) accept the same "ask to join" flow as crews, but keyed to the collective's own coordinate. A skater publishes a public, addressable event (kind `36387`) that `p`-tags the collective's owner so it surfaces in their notification bell. ```json { "kind": 36387, "content": "Been filming the local plaza scene for years — would love to roll with you.", "tags": [ ["d", "35003:<ownerPubkey>:<collectiveDtag>:<requesterPubkey>"], ["a", "35003:<ownerPubkey>:<collectiveDtag>"], ["collective_name", "Barcelona Filmers"], ["p", "<ownerPubkey>"], ["alt", "Collective join request: Barcelona Filmers on SkateSpots"] ] } ``` | Tag | Description | |-----|-------------| | `d` | Collective coordinate + requester pubkey, so re-requesting **replaces** the prior request rather than piling up. | | `a` | The Lab collective coordinate (`<kind>:<owner>:<d>`) where `<kind>` is any Lab craft kind (35001–35009). Clients validate the referenced kind is a Lab craft kind. | | `collective_name` | Cached title for rendering the notification without a second fetch. | | `p` | The collective **owner** — queryable so the owner finds requests addressed to them: `{ kinds: [36387], "#p": ["<me>"] }`. | | `alt` | NIP-31 human-readable summary. | **Flow (mirrors crews):** 1. **Request.** The skater authors the request, binding "who wants in" to their key. 2. **Notify.** The owner's client queries `#p` and surfaces a card linking to the collective's member editor plus a **Dismiss** action. 3. **Add.** Only the owner authors the collective profile, so only they add the requester's `p` tag; the requester then confirms with their own NIP-32 membership attestation, exactly as with any collective member. 4. **Dismiss.** Handled/ignored requests are cleared from the owner's bell via the same synced dismissal set below (keyed by event id). #### Dismissed notifications (kind `30078`) — private, cross-device Neither an invitee nor a moderator authored the invite / join-request they're acting on, so they can't delete it — they can only **stop it nagging them**. That "dismissed" set is kept as a single private, addressable **NIP-78** application-data event (kind `30078`, `d = skatespots:crew-notif-dismissals`) whose `content` is a **NIP-44 payload encrypted to the user's own key**. One list covers invites, crew join-requests, and collective join-requests (all keyed by event id). ```json { "kind": 30078, "content": "<nip44(JSON.stringify({ ids: ["<eventId>", …] }))>", "tags": [ ["d", "skatespots:crew-notif-dismissals"], ["alt", "Private: dismissed SkateSpots crew notifications"] ] } ``` - **Cross-device.** Because it's a published event (not `localStorage`), the dismissal follows the user to every device they log in from — handle a request on your phone and it's gone on your laptop too. - **Private.** The `ids` never leave the encrypted `content`, so relays learn nothing about which invites/requests a user ignored. - **Graceful degradation.** The client always resolves the **union** of this synced list and a local mirror, so dismissals still work instantly, offline, and on signers without NIP-44 (they simply don't sync). #### Private Crew Chat (Cordn / MLS) — the `coordinator` tag A crew's **public** identity (roster, spots, events, treasury, sponsor) lives on Nostr as the kind-`35006` event above. **A crew's private group chat is end-to-end encrypted and on by default** — no setup. The built-in transport is **NIP-17 giftwrap group DM**: every message is sealed to each member's key and published as gift wraps, so only the founder and listed members can read it and relays see only ciphertext. This is the right fit for a semi-public, socially-verified skate crew (cf. Concord's shared-key model for "large casual rooms"). **Optionally**, a technical founder who **self-hosts a [Cordn](https://github.com/Cordn-msg/cordn) coordinator** (an MLS [RFC 9420](https://datatracker.ietf.org/doc/rfc9420/) Delivery Service reached over ContextVM, one per group) can advertise it with the optional `coordinator` tag (see the Optional Tags table) to route chat through it instead. This is a **pointer, not a requirement**: - **On relays:** only the public crew event and a coordinator URL. With the coordinator path, no per-message events hit public relays at all, so there is nothing crew-specific to fingerprint or cluster. - **The coordinator** routes sealed ContextVM traffic and never sees message content or client IPs. If it is unreachable, chat delivery stalls but the group, its membership and its history on members' devices survive; groups can migrate coordinators (per-group cursors preserve ordering). - **Crypto status (honest):** the coordinator path currently uses the same **NIP-44 group encryption** as the built-in chat (see the envelope's crypto seam below) — real E2E confidentiality, but **not yet** an MLS ratchet, so forward secrecy / post-compromise security are a future upgrade, not a present claim. ##### Message envelope (kind `4497`) & delivery When a crew has a `coordinator`, chat runs over it. Each message is a **sealed envelope** — a regular event (kind `4497`) published to the coordinator's ContextVM relay (a `wss://`/`ws://` coordinator URL is used directly; an `https://` coordinator falls back to the ContextVM relay, addressed by the coordinator pubkey): ```json { "kind": 4497, "content": "<sealed JSON — see below>", "tags": [ ["a", "35006:<founder>:<crewDtag>"], // group id = crew coordinate ["t", "skatespots.cordn.crew-chat"], // app envelope marker ["cursor", "1730000000000"], // per-group monotonic cursor (ms) ["p", "<coordinatorPubkey>"], // optional — coordinator addressing ["alt", "Encrypted crew chat message (Cordn)"] ] } ``` Clients do **fetch-then-poll**: a bounded backlog query filtered by `{ kinds:[4497], "#a":[groupId], "#t":["skatespots.cordn.crew-chat"] }`, then a light poll for live delivery. Messages are ordered by the `cursor` tag (event id as tiebreaker). The ciphertext-declared sender is bound to the event author and mismatches are rejected (no spoofing). **Envelope signing (metadata privacy).** The outer kind-`4497` event is signed with a **fresh ephemeral key per message** (NIP-59 style), never the sender's real key — so the coordinator/relay never learns who is talking. Authorship is proven from *inside* the ciphertext (see the v2 author proof below), so hiding the outer identity costs nothing in trust. **Sealed content** (`content` field), JSON — current version is v2: ```json { "v": 2, "alg": "nip44-group-v2", "recipients": { "<memberPubkeyHex>": "<nip44-ciphertext>", "...": "..." } } ``` The inner plaintext is `{ t: body, s: senderPubkey, ts, proof }`, encrypted **once per roster recipient** (and to self) with the sender's real **NIP-44**. Any roster member opens the entry addressed to them — genuine end-to-end confidentiality against the coordinator and relays. `proof` is a **fully-signed inner event** (kind `4498`) authored by the real sender, whose `content` equals the message body and which carries an `["a", groupId]` tag. On open, a client verifies the proof's signature, that its author equals the claimed sender, that its content matches, and that its `a` tag equals this crew — so **authorship is unforgeable even though the outer envelope is anonymous**, and a proof can't be replayed into a different crew. > **Crypto seam — honest status.** `alg: "nip44-group-v2"` gives real E2E > encryption **plus** coordinator-blind sender metadata, but it is **not MLS**: > no forward secrecy / post-compromise security, and O(n) recipient sealing per > message. Known residual: a member could replay another member's *verbatim* > past message within the same crew (they cannot forge new words); full nonce/ > replay hardening and forward secrecy arrive with the MLS upgrade. The client is > split into a **transport** layer (envelope publish/fetch/cursor) and a > **crypto** layer (`sealGroupMessage` / `openGroupMessage`); upgrading to a full > RFC 9420 ratchet via `ts-mls` swaps only the crypto layer (`alg: "mls"`) — > transport, envelope, cursors and UI unchanged. The legacy `v: 1` > (`nip44-group`, sender-signed outer envelope) stays readable for backward > compatibility; clients must treat unknown `alg` values as unreadable rather > than erroring. ### NIP-05 Verification The optional `nip05` tag is a crew handle on **any domain the crew controls** (fully decentralized — SkateSpots does not gatekeep the handle). Because the crew event is authored by the founder, a client verifies the handle by fetching `https://<domain>/.well-known/nostr.json?name=<local>` and checking that the returned pubkey equals the **founder** pubkey: | Result | Meaning | UI | |--------|---------|----| | name → founder pubkey | Verified | Green ✓ handle | | name → different pubkey | Impersonation / stale | Red ⚠ handle | | domain/JSON unreachable | Can't verify right now | Muted handle | | no `nip05` | — | (nothing) | Verification is fetched directly with a CORS-proxy fallback and cached client-side. ### Map Integration Crews with a `g` (home turf) render on the SkateSpots map as teal pins. Clicking a crew pin opens its profile. A crew's `a`-referenced home spots remain regular spots on the map and cross-link back to the crew. ### Querying Crews ```json { "kinds": [35006], "#t": ["skatecrew"], "limit": 200 } ``` ### Crew Agent identity (🐝 Sasquatch) When a crew carries the `["t","crew-agent"]` marker, it opts into an **AI Crew Agent** — a Nostr identity that belongs to the crew and can answer members' questions with live SkateSpots data (spots, events, Lab pros, gear, weather). The agent's keypair is **deterministically derived** from the crew coordinate: ``` secret = sha256("skatespots:crew-agent:v1" | "35006:<founderPubkey>:<dTag>") pubkey = secp256k1(secret) ``` Any client can reproduce the same agent identity for a given crew with no stored state. The agent publishes a kind-0 profile with `bot: true` (NIP-24) named `🐝 Sasquatch · <CrewName>`. > **Security note:** because the salt is public and the seed is public crew data, > this key is *reproducible by anyone* and is **not a secure identity**. It is used > only for the agent to speak for itself (its profile, and — in a later phase — > helper replies in its own crew chat). It holds no funds and must never be trusted > for sensitive signing. A future revision may replace this with a founder-controlled > stored key without changing the querying model (the `crew-agent` marker stays the > same). ## CLINK Payments (`noffer` tag) SkateSpots supports an **optional, opt-in** self-custodial payment code on Lab provider profiles (kinds 35001–35004), skate crews (kind 35006), and skate shop stalls (kind 30017). This is layered on top of — never a replacement for — the existing Lightning Address (`lud16` from kind 0) and zap flows. [CLINK](https://clinkme.dev) (Common Lightning Interface for Nostr Keys) defines Nostr-native Lightning standards. A **CLINK Offer** is a reusable, static payment code encoded as a bech32 string with an `noffer1…` prefix (CLINK kind 21001), analogous to an LNURL-Pay / Lightning Address but requiring no web server or custodian. ### The `noffer` tag | Tag | Value | Description | |-----|-------|-------------| | `noffer` | `noffer1…` | A CLINK Offer code the provider/shop controls. | ```json ["noffer", "noffer1qqs..."] ``` ### Client behavior - The tag is **optional**. Its absence changes nothing. - Clients **MUST** validate the `noffer1…` prefix before rendering a payment affordance. SkateSpots stores it only if it matches `/^noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,}$/`. - SkateSpots does **not** decode or transact the offer itself. It surfaces a "Pay via CLINK" affordance (QR of the code, copy button, and a `nostr:noffer1…` deep link) so a CLINK-compatible wallet (e.g. ShockWallet, Lightning.Pub) can request an invoice directly over Nostr. - The QR scanner also classifies scanned `noffer1…` (and `ndebit1…`) strings so they are recognized as CLINK codes. > Only `noffer` (CLINK Offers) is supported in this release. `ndebit` (CLINK > Debits) and `nmanage` (CLINK Manage) are recognized by the scanner but not yet > actioned in-app. ## Spot Chat (kind 24899) Spot Chat provides **ephemeral, geohash-anchored local chat channels**. Every location on the map — and every skate spot — has a live public channel keyed to its geohash, giving skaters a "here and now" side-groupchat for a place ("anyone skating tonight?"). This is inspired by BitChat's location-channel concept but is **SkateSpots-native**: it uses its own kind and tag conventions and is not intended to interoperate with BitChat's channel format. ### Event Format ```json { "kind": 24899, "content": "<message text>", "tags": [ ["g", "<channel geohash>"], ["n", "skate"], ["alt", "Spot Chat message in geohash <channel>"] ] } ``` ### Behavior - **Ephemeral** (kind in the 20000–29999 range): relays are not expected to store these events permanently, matching the transient nature of local chat. - **Channel = geohash prefix.** The `g` tag holds a geohash truncated to a precision level. Clients query `#g` for the channel key. Because a more precise geohash *starts with* a coarser one, a block-level message naturally appears in the enclosing neighborhood/city channel. | Precision | Geohash length | Approx. area | |-----------|----------------|--------------| | Block | 8 | ~40 m | | Neighborhood | 6 | ~1.2 km | | City | 5 | ~5 km | - Clients display roughly the last 24 hours of messages. - Message content is limited to 500 characters. ### IRC-style commands (client convention) These are a light, optional flavor layer handled entirely client-side; plain typing always works. | Command | Effect | |---------|--------| | `/me <action>` | Content is published verbatim (prefixed with `/me `); clients render it as a third-person action line ("✳ alice does a kickflip"). | | `/who` | Not published — the client shows the count of skaters who posted in the channel in the last 24h. | ### Required Tags | Tag | Description | |-----|-------------| | `g` | Channel geohash (the truncated location key) | ### Optional Tags | Tag | Description | |-----|-------------| | `n` | Namespace marker, always `skate` for SkateSpots channels | | `alt` | NIP-31 human-readable description | ## Spot Status Reports (kind 2259) Skate spots change: a ledge gets knobbed, a plaza gets demolished, security starts busting a spot, or construction closes it temporarily. Because spots (kind 30818) are addressable events owned by one author, and that author often isn't the person who notices the change, SkateSpots uses **community status reports** to keep the map honest. A status report is a lightweight **regular event (kind 2259)** that any user can publish about any spot. The client aggregates recent reports — weighted by **recency** and reporter **trust score** — into a single consensus status shown as a badge on the spot. Reports are append-only; nothing is deleted, so a spot's history is preserved and a "gone" spot can naturally recover if it's later reported "active" again. This is **SkateSpots-native** and not intended to interoperate with other clients. ### Event Format ```json { "kind": 2259, "content": "<optional freeform note, e.g. 'new knobs on the ledge'>", "tags": [ ["a", "30818:<spot-owner-pubkey>:<spot-d-tag>"], ["K", "30818"], ["status", "gone"], ["t", "spot-status"], ["alt", "Spot status report: \"<spot title>\" is gone"] ] } ``` ### Status Values The `status` tag MUST hold one of the following values: | Value | Meaning | |-------|---------| | `active` | 🟢 Good to go — skate it. | | `bust` | 🟡 Heavy kick-out risk (security / cops). | | `skate_stopped` | 🟠 Knobbed / capped, but partially skateable. | | `closed` | ⚪ Temporarily closed (construction / event). | | `gone` | 🔴 Demolished, paved over, or fenced off for good. | ### Required Tags | Tag | Description | |-----|-------------| | `a` | Coordinate of the referenced spot: `30818:<pubkey>:<d-tag>` | | `status` | One of the status values above | ### Optional Tags | Tag | Description | |-----|-------------| | `K` | Kind of the referenced event, always `30818` | | `t` | Category marker, always `spot-status` (relay-indexed for querying) | | `alt` | NIP-31 human-readable description | ### Aggregation (client behavior) - Reports older than **180 days** are ignored (spots change back). - Only the **most recent report per reporter** counts (no ballot-stuffing). - Each report's weight = `recencyWeight × trustWeight`: - **Recency**: exponential decay with a 30-day half-life — fresh reports dominate stale ones. - **Trust**: reporter trust score (0–100) mapped to a 0.2–1.0 multiplier, so everyone has some voice but followed/verified users carry more. - The status with the highest total weight wins; `confidence` = winning weight / total weight. - Spots with a winning status of `gone` are **hidden from map markers** (but still shown dimmed, with a badge, in card lists) so the map isn't cluttered by demolished spots while preserving the record. ## Private Contacts (kind 30000, encrypted) To let a user maintain a personal "who I skate with" address book **without leaking their social graph publicly** (unlike a kind-3 follow list), SkateSpots stores a private, encrypted contacts list as a NIP-51 Follow Set (kind `30000`) with a fixed `d` identifier `skatespots-contacts`. The contact pubkeys are NIP-44 encrypted to the user's **own** key and kept in the event `content` — only the user can read them. This mirrors the "encrypted private list" approach and keeps the scene network self-sovereign and private. ### Event Format ```json { "kind": 30000, "content": "<NIP-44 encrypted JSON array of p-tag pairs>", "tags": [ ["d", "skatespots-contacts"], ["title", "My Skate Contacts"], ["description", "Private, encrypted list of people in my scene."] ] } ``` ### Client behavior - The list is decrypted with the logged-in user's NIP-44 signer. Clients that cannot decrypt (different key) fall back to any public p tags present. - The Contacts page also surfaces crew-mates (members of crews the user belongs to), follows (kind 3), and recent DMs (NIP-17 conversations) as live tabs; any of those can be starred into the private list. - The encrypted list is never merged into the public kind-3 follow list. ## Sasquatch Pro (kind 32671) Sasquatch Pro is an optional Lightning "supporter pass" that unlocks the Sasquatch AI guide's **personal assistant** features (reading the user's own Stash, crews, spots and listings; drafting actions; priority model). It is funded by a NIP-57 zap to the platform Lightning address (`skatespots@minibits.cash`) and is verified **client-side, bound to the paying user's npub** — consistent with SkateSpots' serverless architecture. ### Verification (two sources, strongest first) 1. **Zap receipt (NIP-57, kind 9735) — strong.** At purchase the client signs a kind-9734 zap request that carries `pro-tier` and `pro-expiry` tags. When paid, the platform's Lightning provider publishes a kind-9735 receipt embedding that zap request (in its `description` tag). Because the request is signed by the **user's** key, the receipt cryptographically binds payment to identity and cannot be forged by the user. The client reads `#P`-tagged receipts for the user, parses the embedded request, and takes `pro-expiry`. 2. **Self-signed receipt (kind 32671) — fallback.** If the provider does not emit zap receipts, the client publishes a self-signed addressable receipt so a paying user is never locked out. Weaker (a technical user could self-issue), but it references the payment so a future verifier can validate it retroactively without a schema change. Access = the maximum unexpired expiry across either source. Platform owner pubkeys always have Pro. ### Event Format (kind 32671 — self-signed fallback receipt) ```json { "kind": 32671, "content": "Sasquatch Pro supporter pass", "tags": [ ["d", "sasquatch-pro"], ["tier", "3m"], ["expiry", "1730000000"], ["amount", "12000"], ["p", "<platform pubkey>"], ["alt", "Sasquatch Pro supporter pass (3 months)"] ] } ``` ### Tiers | id | Duration | Sats | |----|----------|------| | `7d` | 7 days | 2,000 | | `1m` | 1 month | 5,000 | | `3m` | 3 months | 12,000 | | `1y` | 1 year | 36,000 | ### Notes - Pro reads only the user's **own** data, client-side. The encrypted Stash is decrypted locally by the signer and never sent to any AI or server. - Free Sasquatch remains fully usable (public spot/event/coach/gear/crew search plus the trick glossary); Pro adds the personal + action layer. ## Newsletter (kind 30023) + Subscription (kind 30078) SkateSpots publishes a community newsletter as standard **NIP-23 long-form articles (kind 30023)**, so issues are readable in any Nostr long-form client (Habla, Highlighter, etc.) and by the app's in-app mailbox. ### Trust boundary An issue is only surfaced in the app (mailbox / notification bell) if it is: 1. **Kind 30023**, and 2. authored by a **SkateSpots admin pubkey** (see `src/lib/admins.ts`), and 3. tagged `["t", "skatespotsnews"]`. This prevents anyone from spamming the community newsletter feed. ### Issue format ```json { "kind": 30023, "content": "<Markdown body of the issue>", "tags": [ ["d", "issue-6"], ["title", "SkateSpots Drop #006"], ["summary", "One-line teaser of the issue"], ["image", "<cover image url>"], ["published_at", "<unix seconds>"], ["t", "skatespotsnews"], ["t", "skatespots"], ["t", "newsletter"], ["alt", "SkateSpots newsletter issue"] ] } ``` ### Subscription marker (kind 30078) A reader opts in to the in-app newsletter mailbox by publishing a per-user **NIP-78 application-specific data** event (kind 30078, addressable): ```json { "kind": 30078, "content": "", "tags": [ ["d", "skatespots:newsletter:subscription"], ["subscribed", "true"], ["alt", "SkateSpots newsletter subscription"] ] } ``` - `subscribed` is `"true"` while subscribed and `"false"` after unsubscribing (the event is replaced, not deleted, so state syncs across devices). - It carries nothing sensitive (just a boolean), so it is published in plaintext, which also lets the community tally an honest subscriber count. - Subscription is opt-in for notifications only: everyone can read issues on the `/newsletter` page regardless. Subscribing routes new issues into the notification bell's "News" tab and unread badge. ## Cross-device app data (kind 30078, encrypted) Beyond the plaintext newsletter-subscription marker above, SkateSpots stores two blobs of personal app state as **NIP-78 application-specific data** events (kind 30078, addressable), each keyed by its own `d` tag and **NIP-44-encrypted to self** so it syncs across the user's devices without being readable by relays or anyone else. The engine behind both is `src/hooks/useSyncedAppData.ts`; `localStorage` remains the instant, offline-first fast path (and the only store for logged-out users), with the encrypted event as the cross-device mirror. | `d` tag | Hook | Content (decrypted JSON) | Merge policy | |---------|------|--------------------------|--------------| | `skatespots:read-state` | `useReadState` | `{ notifications, notificationsSeen, newsletter, dms: {pubkey: ts}, crewChats: {coord: ts} }` — all unix seconds | **Field-wise max** (markers only move forward, so two devices merge by taking the larger timestamp per field — commutative, never clobbers) | | `skatespots:preferences` | `usePreferences` | `{ navMiddleIds?, desktopPrimaryIds?, language?, updatedAt }` | **Last-write-wins by `updatedAt`** (a preference is a genuine replacement, not a monotonic marker) | ### Read-state event ```json { "kind": 30078, "content": "<nip44(JSON.stringify({ notifications, notificationsSeen, newsletter, dms, crewChats }))>", "tags": [ ["d", "skatespots:read-state"], ["alt", "SkateSpots read-state (encrypted)"] ] } ``` Drives the notification bell badge, DM unread indicators, the crew-chat "new messages" divider, and the newsletter unread badge — so clearing them on one device clears them on the others. ### Preferences event ```json { "kind": 30078, "content": "<nip44(JSON.stringify({ navMiddleIds, desktopPrimaryIds, language, updatedAt }))>", "tags": [ ["d", "skatespots:preferences"], ["alt", "SkateSpots preferences (encrypted)"] ] } ``` Carries the customizable bottom-nav / desktop-nav layout (and, going forward, other personal UI settings) so a skater's setup follows their npub between phone and laptop. ### Safety notes - **Clobber-guard.** Both blobs are one replaceable event whose content is the *whole* object. Before every publish the engine re-fetches and re-decrypts the freshest remote copy and merges the local patch into it (per the policy above), so a stale write can never wipe a field another device just set — the same data-safety discipline the private Stash (`kind 30001`) uses. - **Debounced writes.** Read-state changes fire often (every glance at the bell); the engine coalesces rapid changes into at most one relay publish per few seconds and flushes on `pagehide`/`beforeunload`, so relays are never spammed. - **Anon users.** With no signer (or no NIP-44), sync is a no-op and behavior is identical to the previous localStorage-only implementation. --- # Trade Attestations (kind 31871) Marketplace reputation is built on the **Attestations** NIP (<https://nostr.com/naddr1qvzqqqrcvypzp384u7n44r8rdq74988lqcmggww998jjg0rtzfd6dpufrxy9djk8qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsqrrpw36x2um5v96xjmmwwvuhdk8z>), authored by `npub1cn670f663n3ks02jnnlsvd5y88zjnefy8343ykaxs7y3nzzketrsrjwt8a`. A **listing** (kind `30402`) is the *Assertion Event*. When a deal closes, both parties can publish a `31871` Attestation about it. Two reciprocal attestations on the same listing form a **verified trade**. ## Seller — sale completed ```json { "kind": 31871, "tags": [ ["d", "<seller_pubkey>:trade:sold:<listing-d>"], ["a", "30402:<seller>:<listing-d>"], ["s", "valid"], ["p", "<buyer_pubkey>"], ["method", "cash|lightning|trade|other"], ["amount", "45000", "SATS"], ["rating", "9"], ["alt", "SkateSpots: sale completed"] ], "content": "Sold to @buyer. Paid in cash." } ``` ## Buyer — purchase confirmed Same shape, `d` ends `:trade:bought:<listing-d>`, `p` names the **seller**, and `content` is a human description. ## Extension tags (SkateSpots profile) The base NIP has no counterparty field for this use case, so we add: | Tag | Meaning | | --- | --- | | `p` | The **other party** in the trade (required by us, marks a trade attestation) | | `method` | Self-reported payment method: `cash`, `lightning`, `trade`, `other` | | `amount` | Optional self-reported amount, `["amount","45000","SATS"]` | | `proof` | Optional payment evidence. **Claims:** `["proof","lninvoice","<payment_hash>"]` (a self-typed hash). **Verifiable:** `["proof","lnpreimage","<preimage>","<payment_hash>"]` — anyone checks `sha256(preimage) === payment_hash`; `["proof","zap","<receiptId>"]` — a NIP-57 receipt (signature + recipient + amount). Both show as **payment-verified**. `["proof","deposit","<preimage>","<payment_hash>"]` — a **swap-deposit** receipt, verified the same way (gives a barter a real cost). | | `rating` | Optional trade review, `1`–`10` (skateboard decks; 1 = bad, 10 = best). It's the reviewer's opinion, shown on both profiles; it is not proof or a score computed by the app. | `method`/`amount`/`rating` are **self-reported**, never proof of payment or a verified score — the app cannot observe off-app (cash/trade) settlement. **Receipts (money vs goods).** Only the **money leg** is verifiable: an `lnpreimage` proof is checked by hashing, and a `zap` proof by its receipt — both count as **payment-verified**. A **pure hardware swap has no invoice**, so it has no preimage and stays an *attestation*; a `deposit` receipt (a small refundable Lightning deposit) is the way to give a barter a verifiable **cost** without pretending the goods were proven. **Never promote a barter or a plain `lninvoice` claim to "verified".** ## Trust model, sybil/collusion limits (honest) Trades here are **attestations, not escrow receipts**. Anyone can mint a listing and two accounts can confirm each other with no goods exchanged. So the app **never** presents mutual confirmation as proof — it is one signal among others. What we do to blunt fake reputation: - **Lead with distinct counterparties, not raw counts.** The meaningful headline is `verifiedCounterparties` — the number of *different* people a trade was mutually confirmed with. Two accounts trading only with each other can grow the raw count without bound but can never grow this number past `1`. - **Weight by web-of-trust, and cap per partner.** Each counterparty's confirmed trades are weighted by their trust score and capped (max 3) so a single partner can't dominate. - **Flag the pattern.** When a profile has several confirmations but only one distinct counterparty, the UI shows an explicit warning. - **Revocation is honest.** An attestation can be retracted (`s=revoked`) by its author; revoked records are excluded from reputation (they remain on relays, which we do not hide). There is deliberately **no** fake "trustless trade proof" — off-app cash/trade settlement cannot be verified by the app. A payment-proof-based reputation would require assuming Lightning/zaps, which the marketplace intentionally does not. ## States & revocation `["s", "verifying" | "valid" | "revoked"]` follows the NIP's lifecycle. Because `31871` is addressable, re-publishing the same `d` with `["s","revoked"]` retracts an attestation (e.g. a disputed or cancelled deal). ## Attestation Request (kind 31872) When a buyer's deal wasn't recorded by the seller, the buyer publishes a `31872` asking the seller to attest it. The seller's client surfaces the request and fulfils it in one tap, publishing a `31871` that carries the spec's `request` tag pointing back at the `31872`. ```json { "kind": 31872, "tags": [ ["d", "<buyer_pubkey>:trade-request:<listing-d>"], ["a", "30402:<seller>:<listing-d>"], ["p", "<seller_pubkey>"], ["alt", "SkateSpots: trade confirmation requested"] ], "content": "Please confirm our trade for “Deck”." } ``` | Tag | Meaning | | --- | --- | | `d` | `<requestor>:trade-request:<listing-d>` — re-requesting replaces the prior one | | `a` | The listing (Assertion Event) being attested | | `p` | The requested **attestor** (the seller / listing author) | | `request` (on the reply) | On the fulfilling `31871`: `31872:<requestor>:<d>` | Only the listing author can attest a sale, so the UI only prompts a seller to fulfil requests whose `a` author is them, and hides requests they've already answered (they have a `31871` for that listing). ## Reputation semantics - **sales** — listings where the attestor is the listing author - **purchases** — listings where the attestor is not the author - **verified** — seller **and** counterparty both attested the same listing - Consumers **trust-weight counterparties** with the existing web-of-trust score (as with Scene vouches) so fresh-account rings cannot inflate reputation. ## Shipment Status (kind 34967) Optional, **self-reported** logistics for a `ships` deal. Addressable — one slot per author: ```json { "kind": 34967, "content": "", "tags": [ ["d", "ship:<listing-d>:<author>"], ["a", "30402:<seller>:<listing-d>"], ["p", "<counterparty>"], ["status", "shipped|received|issue"], ["proof", "ref", "<tracking-id>"], ["alt", "SkateSpots: shipment status"] ] } ``` - The **seller** marks `shipped`; the **buyer** marks `received`; either can flag `issue`. Re-publishing the same `d` replaces only that author's status. - `p` is the counterparty, so the push service notifies **without decrypting**. - ⚠️ It is a **claim, not proof**: `received` does **not** release escrow, and the app verifies nothing here. No address is ever stored — only a coarse status and an optional tracking reference. ## Escrow (Cashu `cashu_token`) A `31872` may carry `["cashu_token", "<token>"]`. We use Cashu **P2PK with a locktime and a refund key**, funded by paying the mint's Lightning invoice: - buyer locks funds to the **seller's** escrow pubkey (`cashu_escrow` on the seller's profile), with `locktime = now + N days` and the **buyer** as the refund key; - seller delivers → redeems; if not, the buyer refunds after the locktime. This is real escrow (no oracle needed) because the mint enforces the locktime — locking to the seller *without* an expiry would be prepayment, not escrow, so we don't. A plain (unlocked) token must **never** be put in a public event: anyone who sees it can spend it; the lock is what makes publishing safe. Full design, threat model and status: **`ESCROW.md`**. Core logic lives in `src/lib/cashuEscrow.ts` (unit-tested). Escrow is **native-only** (the Cashu wallet is Capacitor-gated). ## Future (from the NIP) - `31873` **Attestor Recommendation** / `11871` **Proficiency** — trusted-trader endorsements and attestor discovery.