# Spaniel Syndicate — Standing Offers

Collectors can place **standing offers** on cards. An offer carries a
**predicate** describing the set of cards it is willing to accept and
a SOL amount the bidder will pay if a matching card is offered for
sale.

**Offers may target cards that do not yet exist** — cards that have
not been minted, packs that have not been opened, or even card keys
that are not yet manifested. The match logic is identical for minted
and unminted cards; once a matching card is revealed, the offer
becomes acceptable.

Predicate logic + validation: `js/lib/offers.js`. Tests:
`tests/offers.test.mjs`.

---

## 1 — Predicate fields

| Field | Type | Meaning |
|---|---|---|
| `seriesId` | string (required) | series scope; only `"s1"` accepted today |
| `exactCardKey` | string | exact match; supersedes all other criteria |
| `dogId` | int [1..69,420] | match any card of this dog |
| `variant` | string | match a specific variant slug |
| `rarityGroup` | string | common / uncommon / rare / ultra / mythic |
| `serialNumber` | int | match exact serial |
| `printCount` | int | match exact print count (sanity filter) |
| `stampType` | string | one of the 6 holiday stamps |
| `plateColor` | string | one of cyan/magenta/yellow/key |
| `isMythic` | bool | filter to mythic-only or non-mythic-only |
| `minVariantRank` | int [1..12] | minimum variant rank (1 = most common, 12 = mythic) |
| `maxVariantRank` | int [1..12] | maximum variant rank |
| `onlyUnminted` | bool | UI display hint (NOT a match criterion) |

Validation rejects unknown fields and out-of-range values. See
`validateOfferPredicate(predicate)`.

---

## 2 — Match semantics

A card matches a predicate when:

- If `exactCardKey` is set: the card's key equals it exactly.
  All other fields are ignored.
- Otherwise: every defined predicate field agrees with the card.
  Undefined predicate fields are wildcards.
- `seriesId` is always required and must match.
- `onlyUnminted` is NOT a match criterion: it only filters the
  display in the UI. After a matching card is minted, the offer
  remains acceptable.

### Examples

| Predicate | Matches |
|---|---|
| `{ seriesId: 's1' }` | any card in series 1 |
| `{ seriesId: 's1', isMythic: true }` | any mythic |
| `{ seriesId: 's1', dogId: 215 }` | any of the 4,468 cards of dog #215 |
| `{ seriesId: 's1', variant: 'gold' }` | any gold card |
| `{ seriesId: 's1', rarityGroup: 'ultra' }` | any error or black 1/1 |
| `{ seriesId: 's1', dogId: 215, variant: 'gold' }` | any of dog #215's 3 gold cards |
| `{ seriesId: 's1', exactCardKey: 's1-mythic-dog-00069-1of1' }` | only that exact mythic |
| `{ seriesId: 's1', minVariantRank: 8 }` | rank ≥ 8 (plate or rarer) |
| `{ seriesId: 's1', maxVariantRank: 11 }` | anything except mythics |

---

## 3 — Offer lifecycle

```
1. Create   POST /api/offers/create/quote
            POST /api/offers/create
            Bidder funds an escrow PDA (preferred) or signs an off-chain
            offer (temporary fallback).
            Worker records the offer + predicate in market_offers.
            State: ACTIVE
            ↓
2. Display  Offer appears on:
            - Marketplace offer feed (/market/offers)
            - Dog page (/dog/<id>) for any dog the predicate covers
            - Card page (/card/<key>) for any matching card
            - Bidder's affiliate dashboard if bidder is a referrer
            ↓
3. Accept   Card owner sees a "an offer matches your card" CTA.
            POST /api/offers/accept/quote
            POST /api/offers/accept
            Series 1 Program verifies the card matches the predicate, atomically
            transfers card → bidder, SOL → seller (minus 2% fee → referrer +
            treasury).
            State: FILLED
            ↓
4. Cancel   POST /api/offers/cancel/quote
            POST /api/offers/cancel
            Series 1 Program returns escrowed SOL to bidder.
            State: CANCELLED
            ↓
5. Expire   If `expiresAt` is set and passes without being filled or cancelled,
            Worker marks offer EXPIRED on rollup. Escrow remains until the
            bidder claims it (`cancel`).
            State: EXPIRED
```

Offer states: `ACTIVE` | `PARTIALLY_FILLED` (multi-fill offers,
optional) | `FILLED` | `CANCELLED` | `EXPIRED`.

---

## 4 — Funding model

### Preferred: escrowed offers

The bidder locks SOL in a marketplace offer escrow PDA at offer
creation time. The Series 1 Program is the only authority that can move
that SOL: it releases SOL to the seller on accept, and back to the
bidder on cancel.

Pros: instant guaranteed accept; no counterparty risk; seller sees
"acceptable" status confidently.

Cons: SOL locked while offer is open; gas cost to create/cancel.

### Temporary fallback: signed off-chain offers

The bidder signs an offer payload (predicate + price + expiry +
nonce). The Worker stores the signature. To accept, the seller and
the Worker construct an atomic-swap tx that requires the bidder's
signature; the bidder must be online to co-sign.

Pros: no funds locked.

Cons: not instant — bidder must respond; copy must clearly say
"signed offer; bidder acceptance still required".

If the Series 1 Program ships with escrowed offers, the signed-offer path is removed.
If the Series 1 Program ships without, the signed-offer path remains as a labeled
temporary state with a "v2 escrowed offers coming online" banner.

---

## 5 — Fee + referral split on accept

Accepting a standing offer is settled through the same 2% / 50%
marketplace fee as any other marketplace buy:

```
salePrice         = offerPriceLamports
marketFee         = floor(salePrice * 200 / 10000)
sellerReceives    = salePrice - marketFee

# If the offer was created through a valid referral attribution:
referralFee       = floor(marketFee * 5000 / 10000)
treasuryFee       = marketFee - referralFee

# Otherwise:
referralFee       = 0
treasuryFee       = marketFee
```

The referral attribution belongs to the **offer maker** (the bidder)
— it is captured at offer-creation time. A separate referral
session for the seller does not change the split.

---

## 6 — Worker endpoints

| Endpoint | Purpose |
|---|---|
| `GET  /api/offers` | List offers with predicate filters |
| `GET  /api/offers/:id` | Offer detail |
| `POST /api/offers/create/quote` | Signed quote for offer creation |
| `POST /api/offers/create` | Record on-chain offer creation |
| `POST /api/offers/cancel/quote` | Signed quote for cancel |
| `POST /api/offers/cancel` | Record cancel |
| `POST /api/offers/accept/quote` | Signed quote for accept |
| `POST /api/offers/accept` | Record accept |
| `GET  /api/dogs/:dogId/offers` | All standing offers covering dog |
| `GET  /api/cards/:cardKey/offers` | All standing offers covering exact card |

See `docs/WORKER_API_CONTRACT.md` for full request/response shapes.

---

## 7 — Cross-references

- `js/lib/offers.js` — predicate validation + matching.
- `tests/offers.test.mjs` — invariants.
- `docs/MARKETPLACE.md` — listing/buy flows, fee math.
- `docs/CARD_VARIANTS.md` — variant rank table.
- `docs/ONCHAIN_MINT_REQUIREMENTS.md` — Series 1 offer escrow spec.
