3680 lines
145 KiB
Markdown
3680 lines
145 KiB
Markdown
# 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). |
|
||
| `sponsorships_open` | boolean | Explicit opt-in: the skater is open to sponsorship/collab conversations. Absence means **not** opted in; never infer this from activity. |
|
||
| `sponsorships_note` | string | Optional short note shown on the sponsor/collab card when `sponsorships_open` is true. |
|
||
| `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=<nprofile>` 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`. |
|
||
| `cashu_mint` | string | Preferred Cashu mint URL for marketplace escrow (`https://…`). Buyers are nudged to lock funds at this mint, since a mint the seller already uses is one they can redeem from. Optional; 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.",
|
||
"sponsorships_open": true,
|
||
"sponsorships_note": "Open to deck, shoe, travel and event partners.",
|
||
"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": "<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) |
|
||
| `nip05` | Optional shop NIP-05 handle(s) (`local@domain`). **Repeatable** (up to 5), each verified against the **owner** pubkey — the shop's own handle, independent of the owner's profile handle. See Shop Ownership Verification. |
|
||
| `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` |
|
||
| `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. The
|
||
comment target is an **addressable** event (the spot), so the NIP-22 **root
|
||
scope uses UPPERCASE tags** — `A` (root coordinate), `K` (root kind) and `P`
|
||
(root author). Lowercase `A`/`a` is only for a *parent* reference and is not
|
||
written for a top-level comment.
|
||
|
||
```json
|
||
{
|
||
"kind": 1111,
|
||
"content": "Suggested tags for Hollywood High: #marble #ledge #10-stair",
|
||
"tags": [
|
||
["A", "30818:<spot-pubkey>:<spot-d-tag>"],
|
||
["K", "30818"],
|
||
["P", "<spot-pubkey>"],
|
||
["t", "skatespot-tags"],
|
||
["t", "marble"],
|
||
["t", "ledge"],
|
||
["t", "10-stair"]
|
||
]
|
||
}
|
||
```
|
||
|
||
The `skatespot-tags` marker distinguishes tag suggestions from regular comments.
|
||
Readers query **both** `#A` (new) and `#a` (legacy) so suggestions published
|
||
before this correction still resolve. `spot-transfer` notices use the same
|
||
uppercase root scope plus `["p", "<recipient>", "", "new-owner"]`.
|
||
|
||
### 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", "30818:<original-owner-pubkey>:<original-d-tag>"],
|
||
["claim-reason", "abandoned"]
|
||
]
|
||
}
|
||
```
|
||
|
||
`claim-from` is the **original coordinate** (`30818:<pubkey>:<d>`), not just the
|
||
author pubkey — a pubkey alone is ambiguous when an author has several spots.
|
||
(Legacy events that stored a bare pubkey are ignored by the parser.)
|
||
|
||
Note: Since Nostr is decentralized, the original spot remains. This is a known
|
||
limitation: an **unapproved** claim is a parallel listing, so the same physical
|
||
spot can appear twice until a client resolves the `claim-from` chain. **Reader-
|
||
side supersede is not implemented yet** — resolving it safely (only for an
|
||
approved private claim, or an aged original) is an open design item. For now a
|
||
claimed listing is shown with a **"Claimed listing"** notice so the duplicate is
|
||
at least understandable.
|
||
|
||
## 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.
|
||
|
||
### Access Pass (kind 35679)
|
||
|
||
A signed credential issued by an owner to a specific holder. The owner's Nostr
|
||
key signs the event and it is **bound to what it grants access to** via an `a`
|
||
tag — a **venue spot** (`30818`), a **shop** (`30017`), or an **event** (`31923`,
|
||
an organiser's check-in pass). Anyone can verify it by scanning the QR.
|
||
|
||
**Constraint:** an Access Pass may only be issued by the key that **owns** the
|
||
target (`a`). Clients verify the issuer's pubkey matches the target event's
|
||
`pubkey` — a venue/shop owner, or the event organiser.
|
||
|
||
```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"]
|
||
]
|
||
}
|
||
```
|
||
|
||
**Target binding (`a`) — REQUIRED for new passes:** it points at what the pass
|
||
grants access to — a **venue spot** (`30818`), a **shop** (`30017`), or an
|
||
**event** (`31923`). A pass is only issuable by the **key that owns the target**,
|
||
and the `/verify` page fetches it and confirms its **author pubkey equals the
|
||
pass's signer**. The `/card` studio only offers the Park/Event types when you
|
||
own a venue/event, and won't publish without one. (Legacy passes with no `a`
|
||
still verify as a signature but are shown as **not bound**.)
|
||
|
||
### 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", "3"],
|
||
["style", "<Street|Park|Vert|Bowl|DIY|All-terrain>"],
|
||
["skill", "<skill-id>"],
|
||
["sponsor", "<text>"],
|
||
["rides-for", "<text>"],
|
||
["accent", "<#rrggbb>"],
|
||
["logo", "<image-url>"],
|
||
["back", "1"],
|
||
["show", "spots"],
|
||
["show", "zaps"],
|
||
["show", "events"],
|
||
["show", "crews"],
|
||
["show", "local"],
|
||
["show", "roles"],
|
||
["alt", "SkateSpots Rider Card"]
|
||
]
|
||
}
|
||
```
|
||
|
||
- `accent` overrides the theme's primary accent (CSS `--ss-accent`; the canvas
|
||
palette too). `logo` is an optional personal crest (Blossom URL). `back:1`
|
||
enables a printable back side (`back:0` disables). `v:3` added these.
|
||
|
||
- `v:2` marks the `show` flags as explicit, so hiding a facet persists. An event
|
||
with **no** `v`/`show` tags defaults every facet to visible; `v:1` cards (which
|
||
never had facet flags) also default the three facets ON rather than hiding them.
|
||
- The card shows **witnessed** badges (kind 1293) in gold ⭐ and **Lab roles** as
|
||
blue chips. Self-claimed `skill` tags are **no longer written** (unverifiable,
|
||
marginal) and `style` is no longer stored — **style + level are DERIVED from the
|
||
Skater profile** (kind 35000). `parseRiderCard` still reads legacy `style`/
|
||
`skill` tags so old cards don't break.
|
||
- **Identity facets are derived LIVE, never stored on the card:** crews come from
|
||
the author's crew memberships (kind 35006), the local from their skater profile
|
||
(kind 35000 `location`/`home_spot`), and the roles from their own Lab profiles
|
||
(kinds 35001–35014). The `show` flags above only decide what the card renders —
|
||
so the card can never claim a role or crew the skater doesn't actually have.
|
||
|
||
### Crew Card (derived from the crew's own kind 35006)
|
||
|
||
There is **no separate crew-card event** — a crew card is a *render of the crew's
|
||
own signed event* (kind 35006, addressable). Nothing extra is published: the
|
||
founder's key already signed the crew, so the card's QR verifies that event via
|
||
`/#/verify/<naddr>` exactly like a pass. The card shows the crew name, logo, home
|
||
turf, founded year, member count, home-spot count, vibes, and an **Open call**
|
||
badge when the crew is hiring (`lookingFor`). Pick a crew in the `/card` editor.
|
||
|
||
### Event pass / wristband (derived from the event's own kind 31923)
|
||
|
||
Same pattern: **no new event.** A pass is a render of the calendar event
|
||
(`kind 31923`, addressable) that the organiser already signed, so its QR verifies
|
||
that event via `/#/verify/<naddr>`. The `/card` editor's **Event** type lists
|
||
events you organise **or have RSVP'd going/interested to** (`useMyRsvpCoords`,
|
||
kind 31925), and the pass shows the event title, date/time, venue, cover image,
|
||
**who it admits** (your display name) and the going count. `/#/verify/:naddr`
|
||
renders kind `31923` too and links to the event page.
|
||
|
||
**Ticket vs host (2026-10-02):** an attendee's card is labelled **"MY TICKET"**
|
||
and an organiser's **"EVENT · HOST"** — a self-made card is never presented as
|
||
organiser-issued. A ticket's QR adds **`?h=<holder-npub>`**, so `/verify` fetches
|
||
the holder's **RSVP (kind 31925, `#a` = the event)** and shows *Ticket holder* +
|
||
*RSVP: Going / Interested / No RSVP found* — a real door check with no server.
|
||
|
||
### 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` |
|
||
| 35015 | Clothing Designer | `skateclothing` |
|
||
| 35016 | Transport / Driver | `skatetransport` |
|
||
|
||
> 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`.
|
||
|
||
#### Shared enrichment (all Lab crafts)
|
||
|
||
Every Lab provider kind (35001–35016) may also carry the same craft-agnostic
|
||
enrichment tags — how they work, how to pay them, and their media. They are
|
||
repeatable, **not relay-indexed** (clients filter them client-side), and parsed
|
||
by **`src/lib/labExtras.ts`** (`parseLabExtras`), which the shared profile detail
|
||
block (`LabExtrasSection`) renders for every craft:
|
||
|
||
| Tag | Values | Meaning |
|
||
|-----|--------|---------|
|
||
| `language` | free-form | Languages the provider works in |
|
||
| `availability` | `weekdays`, `weekends`, `evenings`, `after-school`, `school-holidays`, `by-appointment`, `seasonal` | When they're available |
|
||
| `booking` | URL | Optional booking / scheduling link |
|
||
| `rate_amount` + `rate_currency` + `rate_unit` | number · `sats`/`USD`/`EUR`/`GBP` · `hour`/`session`/`half-day`/`day`/`project`/`event`/`gig`/`shoot`/`piece`/`cut`/`month` | Structured rate (legacy freeform `rate` still renders and doubles as a note) |
|
||
| `payment_method` | `cash`, `lightning`, `cashu`, `bank`, `other` | Accepted methods — a **declaration**, not an integration |
|
||
| `payment_note` | free-form | Note about payment |
|
||
| `booking_fee` + `booking_fee_currency` | number · currency | Optional fee to confirm a booking (paid directly to the provider) |
|
||
| `pay_what_you_can` | `"true"` | Sliding-scale pricing |
|
||
| `photo` | URL | Repeatable portfolio gallery (max 12) |
|
||
| `video` | URL | Highlights reel (YouTube / Instagram) |
|
||
|
||
> **Money note:** the app never holds funds, never collects fiat/KYC, and never
|
||
> signs a payment event. A `booking_fee` is paid **directly** to the provider's
|
||
> kind-0 `lud16` and is **not escrowed/refundable through SkateSpots**. Fiat/card
|
||
> rails and a refundable deposit (which would need the native-only Cashu escrow)
|
||
> are deliberately out of scope. No tag named `payment` — that's the marketplace
|
||
> offer pointer.
|
||
|
||
**Craft-specific detail fields.** Each craft may add a couple of freeform tags
|
||
for context; they're stored verbatim and rendered by `src/lib/craftFields.ts`
|
||
(so a field with no entry there is invisible):
|
||
|
||
- **Artist**: `deliverables`, `turnaround`
|
||
- **Graffiti**: `surfaces`, `max_size`
|
||
- **Musician**: `set_length`, `tech_rider`
|
||
- **Judge**: `travel`, `certifications`
|
||
- **Organizer**: `insurance`, `permits`
|
||
- **Tattoo**: `deposit`, `minimum`
|
||
- **Trainer**: `modalities`
|
||
- **Barber**: `duration`
|
||
- **Clothing**: `minimum`, `turnaround`, `sizing`, `samples`, `customization`
|
||
- **Filmer**: `turnaround`, `deliverables`, `drone`
|
||
- **Editor**: `turnaround`, `deliverables`
|
||
- **Photographer**: `turnaround`, `deliverables`
|
||
- **Builder**: `lead_time`, `warranty`
|
||
|
||
### 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.
|
||
|
||
### Coach (kind 35001)
|
||
|
||
Skate coaches teaching tricks, terrain and fundamentals. Uses the common base
|
||
schema; skills are encoded as `t` tags (e.g. `basics`, `ollies`, `kickflips`,
|
||
`grinds`, `manuals`, `inverts`, `vert`, `bowl`, `street`, `freestyle`, `safety`,
|
||
`diy`).
|
||
|
||
Coach-specific optional tags record how (and to whom) they coach, plus
|
||
self-attested safety credentials. All are repeatable and lowercased; like
|
||
`experience`/`rate` they are **not relay-indexed**, so clients filter them
|
||
client-side.
|
||
|
||
| Tag | Values | Description |
|
||
|-----|--------|-------------|
|
||
| `format` | `one-on-one`, `group`, `camp`, `video-analysis`, `contest-prep`, `park-lesson`, `adaptive`, `girls-session`, `after-school` | How sessions are delivered |
|
||
| `level` | `beginner`, `intermediate`, `advanced`, `competition` | Levels taught |
|
||
| `age` | `kids`, `teens`, `adults`, `seniors` | Age groups coached |
|
||
| `cert` | `first-aid`, `cpr`, `safeguarding`, `background-check`, `insured`, `federation`, `qualification`, `judge` | Self-attested credentials / safety |
|
||
| `cert_note` | free-form | Optional — names an exact certification / issuer |
|
||
| `language` | free-form (e.g. `English`, `Español`) | Languages spoken |
|
||
| `availability` | `weekdays`, `weekends`, `evenings`, `after-school`, `school-holidays`, `seasonal` | When available |
|
||
| `booking` | URL | Optional booking / scheduling link |
|
||
| `photo` | URL | Repeatable — portfolio gallery image (max 12) |
|
||
| `video` | URL | Optional highlight-reel link (YouTube / Instagram) |
|
||
| `rate_amount` | number | Optional structured rate amount (with `rate_currency` + `rate_unit`) |
|
||
| `rate_currency` | `sats`, `USD`, `EUR`, `GBP` | Currency for `rate_amount` |
|
||
| `rate_unit` | `hour`, `session`, `half-day`, `day`, `camp` | Billing unit for `rate_amount` |
|
||
| `payment_method` | `cash`, `lightning`, `cashu`, `bank`, `other` | Repeatable — declared accepted payment methods |
|
||
| `payment_note` | free-form | Optional note about payment (e.g. "first session free") |
|
||
| `booking_fee` | number | Optional fee to confirm a booking (with `booking_fee_currency`) |
|
||
| `booking_fee_currency` | `sats`, `USD`, `EUR`, `GBP` | Currency for `booking_fee` |
|
||
| `pay_what_you_can` | `"true"` | Sliding-scale / pay-what-you-can pricing |
|
||
|
||
The legacy freeform `rate` tag is still supported and shown when no structured
|
||
rate is present; when both exist, `rate` is treated as a note beneath the price.
|
||
|
||
The Lab's coaching filter drawer offers these as facet filters (formats, levels,
|
||
ages, credentials, availability, languages), populated only from values present
|
||
in the loaded profiles. Filtering is client-side, consistent with the tags not
|
||
being relay-indexed.
|
||
|
||
#### Payment methods & booking fee
|
||
|
||
`payment_method` is a **declaration, not an integration** — it just tells a
|
||
client how the coach accepts payment (including `cash` on site). The app never
|
||
holds funds, never collects fiat/KYC, and never signs a payment event.
|
||
|
||
A `booking_fee` is a small amount **paid directly to the coach** (their kind-0
|
||
`lud16` Lightning address) to confirm a booking; in-app payment is offered only
|
||
when the amount is in `sats` and the coach has a Lightning address. It is **not
|
||
escrowed and not refundable through SkateSpots** — clients must say so plainly.
|
||
A genuinely refundable deposit would require the Cashu escrow rail (native-only,
|
||
unverified), which is deliberately **not** wired into coach bookings.
|
||
|
||
> **Trust note:** `cert` tags are **self-attested**. SkateSpots does not verify
|
||
> them — only a verified NIP-05 domain (or a NIP-32 moderation verdict) is a real
|
||
> signal. Clients must label credentials as self-attested, never as verified.
|
||
|
||
### 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`).
|
||
|
||
### Transport / Driver (kind 35016)
|
||
|
||
Skaters who can move people, gear or cargo — cars, taxis, vans, motorcycles,
|
||
helicopters, planes and boats. **Self-attested:** the app does not verify
|
||
licences or insurance; trust comes from WoT, vouches, reviews and reports.
|
||
Uses the common base schema.
|
||
|
||
Categories encoded as `t` tags:
|
||
|
||
- **Vehicles**: `car`, `taxi`, `van`, `moto`, `helicopter`, `plane`, `boat`, `other`
|
||
- **Services**: `people`, `goods`, `gear`, `airport`, `venue`, `tours`, `logistics`, `other`
|
||
- **Capabilities**: `cargo`, `bike_board`, `wheelchair`, `pets`, `border`, `night`, `covered`, `group`
|
||
|
||
Transport-specific optional tags:
|
||
|
||
| Tag | Description |
|
||
|-----|-------------|
|
||
| `capacity` | Passenger / board capacity note. |
|
||
| `cargo` | Cargo space / weight / volume note. |
|
||
| `coverage` | Routes, regions or cross-border coverage. |
|
||
| `availability` | Freeform availability. |
|
||
| `base` | Base area / airport. |
|
||
| `accessibility` | Ramp, wheelchair space, assistance, etc. |
|
||
|
||
A transport company 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, 35015, 35016],
|
||
"#t": ["skatecoach", "skatefilmer", "skateeditor", "skatephotographer", "skatebuilder", "skateartist", "skategraffiti", "skatetattoo", "skatetrainer", "skatemusician", "skatejudge", "skateorganizer", "skatebarber", "skateclothing", "skatetransport"],
|
||
"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`). **Repeatable** (up to 5): a crew may advertise several handles, each verified independently against the **founder** pubkey; the first is canonical. (Unlike a kind-0 profile, whose `nip05` is a single string — a crew is our own tag convention, so it isn't limited to one.) 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). The tag is
|
||
**repeatable (up to 5)**: a crew may advertise several handles and clients verify
|
||
each one independently (the first is canonical / used for back-compat). Because
|
||
the crew event is authored by the founder, a client verifies a 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 code with a real **bech32 + TLV** decode
|
||
(`src/lib/clink.ts`; the previous regex accepted typo'd codes). Tag `0`
|
||
(32-byte service pubkey) is required; unknown TLVs are ignored per NIP-19.
|
||
- The code is shared **as-is** — the raw `noffer1…` string. Per the CLINK spec
|
||
there is **no `nostr:`, `noffer:`, or BIP-21 wrapper**; QR payloads are that
|
||
plain string.
|
||
- SkateSpots does **not** decode-to-pay the offer itself. It surfaces a
|
||
"Tip in Bitcoin" affordance (QR of the raw code + a copy button) so a
|
||
CLINK-compatible wallet (e.g. ShockWallet, Lightning.Pub, ZEUS) can request an
|
||
invoice directly over Nostr. It **does** decode the TLV to show the offer's
|
||
pricing (fixed / variable-currency / spontaneous) and offer id/relay.
|
||
- The QR scanner classifies scanned `noffer1…` (and `ndebit1…`) strings, each
|
||
validated by the same bech32+TLV decode.
|
||
|
||
> 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 — and the app **does not** present `clink` as a payable
|
||
> marketplace offer pointer (payable pointers remain `lninvoice`/`lnaddress`).
|
||
|
||
## 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"]
|
||
]
|
||
}
|
||
```
|
||
|
||
### Weekly draft (kind 30078)
|
||
|
||
`scripts/newsletter-weekly.ts` publishes the generated issue as a **public
|
||
NIP-78 draft** before an admin publishes it:
|
||
|
||
```json
|
||
{
|
||
"kind": 30078,
|
||
"content": "<JSON NewsletterDraft: number,title,slug,summary,image,body,nostrText,coverTitle,createdAt>",
|
||
"tags": [
|
||
["d", "skatespots:newsletter:draft"],
|
||
["t", "skatespotsnewsdraft"],
|
||
["alt", "SkateSpots weekly newsletter draft #007"]
|
||
]
|
||
}
|
||
```
|
||
|
||
The draft is authored by an admin and read only by the admin composer. It is
|
||
plaintext/public (no subscriber list, keys or private messages) and is **not**
|
||
an issue: subscribers only see a later kind-30023 publish. Set
|
||
`NEWSLETTER_AUTOPUBLISH=1` to have the same run publish the issue and teaser.
|
||
|
||
### 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).
|
||
|
||
## Private-spot ownership claims
|
||
|
||
A private spot (`kind 30818` with `access=private`) can't be verified against a
|
||
domain the way a shop can, so ownership is a **two-step, moderator-gated claim** —
|
||
a stranger can never be auto-granted someone's pin.
|
||
|
||
1. **Claim (the claimant).** A logged-in user **with a NIP-05** publishes a
|
||
NIP-32 kind 1985 label of their own:
|
||
|
||
```json
|
||
{ "kind": 1985, "content": "<their nip05 handle>", "tags": [
|
||
["L", "org.skatespots.spotclaim"],
|
||
["l", "claim", "org.skatespots.spotclaim"],
|
||
["a", "30818:<owner-pubkey>:<d>"],
|
||
["p", "<owner-pubkey>"],
|
||
["alt", "SkateSpots private-spot ownership claim"]
|
||
] }
|
||
```
|
||
|
||
The namespace is **separate** from `org.skatespots.moderation`, so
|
||
`useAdminModeration` never reads a non-admin label as a verdict.
|
||
|
||
2. **Approve (a moderator).** An admin publishes `claim-approved` in the standard
|
||
moderation namespace, tagged with the spot coordinate **and** the claimant:
|
||
`["L","org.skatespots.moderation"]` · `["l","claim-approved",…]` ·
|
||
`["a","30818:<owner>:<d>"]` · `["p","<claimant>"]`.
|
||
|
||
3. **Finish (the claimant).** On approval the claimant re-publishes the spot
|
||
under their own key with `["claim-from","30818:<owner>:<d>"]` and
|
||
`["claim-reason","private-approved"]` — the existing claim mechanism. They are
|
||
now the author/owner.
|
||
|
||
⚠️ Honest limits: a valid claim proves **who controls a Nostr key and passed
|
||
NIP-05** — NOT that they own the property. Approval is a human judgement. The
|
||
original spot stays on Nostr (decentralized); nothing is deleted. Only
|
||
**private** spots use this; non-private spots keep the abandoned-claim flow.
|
||
|
||
## Future (from the NIP)
|
||
|
||
- `31873` **Attestor Recommendation** / `11871` **Proficiency** — trusted-trader
|
||
endorsements and attestor discovery.
|