Phase 0 — identity (lib/crewAgent.ts): - Deterministic per-crew agent keypair derived from the crew coordinate (sha256 of a public salt + coord). Documented security tradeoff: the key is reproducible by design and holds no funds — used only for the agent to speak for itself. Includes kind-0 profile builder (bot:true, NIP-24) and a signAsCrewAgent helper for Phase 3. Phase 1 — opt-in toggle: - SkateCrew.agentEnabled parsed from a queryable ["t","crew-agent"] marker. - "Crew Agent" toggle (Beta) in CrewForm; publishes the marker when on. - NIP.md documents the crew-agent tag and the agent identity derivation. Phase 2 — crew-scoped Sasquatch with receipts: - lib/sasquatchBrain.ts: shared AI caller (model fallback) + tool-call parser. - useCrewAgentChat: reuses the CVM data tools, seeded with crew context (name/location) so "near us" resolves to the crew's turf. Ephemeral/local, no keys, no publishing yet. - CrewAgentPanel: chat dialog rendering agent answers PLUS structured receipt cards (spots / events / Lab pros / gear / crews) linking into the app. - CrewDetailPage: "Ask Sasquatch" card + panel, shown only when the crew enabled the agent. Also: Lab category tabs → fixed 5-across grid (no horizontal scroll). Bump 1.0.68. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
1993 lines
65 KiB
Markdown
1993 lines
65 KiB
Markdown
# SkateSpots Protocol
|
||
|
||
This document defines the Nostr event kinds used by the SkateSpots application for managing skateboarding locations, shops, and related content.
|
||
|
||
## 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": "<shop description>",
|
||
"tags": [
|
||
["d", "<unique-identifier>"],
|
||
["name", "<shop name>"],
|
||
["g", "<geohash>"],
|
||
["location", "<shop address>"],
|
||
["t", "skateshop"],
|
||
["phone", "<phone number>"],
|
||
["email", "<email address>"],
|
||
["website", "<website URL>"],
|
||
["instagram", "<instagram handle>"],
|
||
["hours", "<hours of operation>"],
|
||
["imeta", "url <image-url>", "m image/jpeg"],
|
||
["alt", "Skate shop: <name>"]
|
||
]
|
||
}
|
||
```
|
||
|
||
### 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": "<detailed description of the skate spot>",
|
||
"tags": [
|
||
["d", "<unique-identifier>"],
|
||
["title", "<name of the skate spot>"],
|
||
["g", "<geohash>"],
|
||
["location", "<human-readable address>"],
|
||
["category", "<spot-category>"],
|
||
["t", "skatespot"],
|
||
["t", "<feature-tag>"],
|
||
["t", "<surface-tag>"],
|
||
["t", "<vibe-tag>"],
|
||
["imeta", "url <image-url>", "m image/jpeg"],
|
||
["alt", "Skate spot: <title>"]
|
||
]
|
||
}
|
||
```
|
||
|
||
### 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` |
|
||
| `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 |
|
||
|
||
### 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": "..."
|
||
}
|
||
```
|
||
|
||
## 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.
|
||
|
||
## 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) |
|
||
| `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"]
|
||
}
|
||
```
|
||
|
||
## 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 (e.g., "Weekends", "By appointment") |
|
||
| `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,
|
||
"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
|
||
}
|
||
```
|
||
|
||
### 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. Shared manually via DM. |
|
||
| `friends` | Visible to mutual follows. Enables P2P matching. |
|
||
| `public` | Visible to anyone who views the user's profile. |
|
||
|
||
### 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>"],
|
||
["alt", "Skatepark Access Card"]
|
||
]
|
||
}
|
||
```
|
||
|
||
### 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 License (client-side only)
|
||
|
||
The personal Rider License is generated client-side from the user's kind 0 profile metadata and on-chain stats. It is not a separate Nostr event — it renders from existing data:
|
||
|
||
- Name, avatar, NIP-05: from kind 0
|
||
- 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
|
||
- Park cards held: count of kind 35679 with `#p` = user
|
||
- Witnessed skill badges: count of kind 1293 with `#p` = user
|
||
|
||
The Rider License includes two categories of skill badges:
|
||
- **Self-claimed** — selected by the rider themselves, shown in theme color
|
||
- **Witnessed** — awarded by others (kind 1293), shown in gold ⭐
|
||
|
||
The "Rides For" field displays the local shop the rider represents, distinct from sponsors (brands).
|
||
|
||
### Verification
|
||
|
||
The QR code on printed Park Access cards encodes the Nostr address (`naddr`) of the issuing event. Verifiers scan the QR, fetch the event from any relay, and confirm the issuer's signature.
|
||
|
||
```
|
||
URL format: https://skate-spots.org/verify/<naddr>
|
||
```
|
||
|
||
## 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": "..."
|
||
}
|
||
```
|
||
|
||
## 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` |
|
||
|
||
### 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".
|
||
|
||
### 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"]
|
||
]
|
||
}
|
||
```
|
||
|
||
### Querying Lab Profiles
|
||
|
||
```json
|
||
{
|
||
"kinds": [35001, 35002, 35003, 35004, 35005],
|
||
"#t": ["skatecoach", "skatefilmer", "skateeditor", "skatephotographer", "skatebuilder"],
|
||
"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>"],
|
||
["a", "30818:<spotFounder>:<spotDtag>"],
|
||
["nip05", "barcelonanightcrew@bcnnightcrew.example"],
|
||
["website", "https://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 — covers filming, photography & editing), or `crew-focus-coaching`. 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 |
|
||
| `noffer` | Optional shared tip jar (CLINK Offer — see below) |
|
||
|
||
### 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
|
||
|
||
Because only the founder can write the crew's `p` (member) list, an admin cannot
|
||
silently add members. Every moderator can **invite** — share a link/message to
|
||
the crew page where the invitee, once listed by the founder, confirms membership
|
||
with their own key (the standard bilateral attestation above). Founders get an
|
||
extra shortcut to add a member key directly in crew settings.
|
||
|
||
### 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 |
|