diff --git a/docs/superpowers/specs/2026-05-19-descro-flow-design.md b/docs/superpowers/specs/2026-05-19-descro-flow-design.md new file mode 100644 index 0000000..ab4c16d --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-descro-flow-design.md @@ -0,0 +1,414 @@ +# Descro — Protocol Flow Design +_2026-05-19_ + +## Vision + +Ein dezentrales Escrow-Protokoll für physische Waren auf Solana. `descro` ist ein universeller, agnostischer Escrow-Primitive. Darüber liegt `solisting` (Listing + Order Flow). Beide Programme sind vollständig unabhängig voneinander — keine gegenseitigen Abhängigkeiten, keine hardcoded Program-IDs mit Sonderrechten. + +--- + +## Architektur + +``` +APPLICATION LAYER + Zentrale Platform (custodial Wallets, normale UX) + Dezentrale Clients (direkter Protokoll-Zugriff) + │ +PROTOCOL LAYER + ┌─────────────────────┐ + │ solisting │ ← Listings + Order/Consent Flow + │ (descro_market) │ + └──────────┬──────────┘ + │ CPI (permissionless, kein Sonderrecht) + ┌──────────▼──────────┐ ┌──────────────────────────┐ + │ descro │───▶│ descro_ext_resolvers │ + │ (Escrow + Vault) │CPI │ (Resolver Registry) │ + └─────────────────────┘ └──────────────────────────┘ +``` + +**Kernprinzip:** `descro` kennt `solisting` nicht. Jeder kann `descro` direkt nutzen. `solisting` ist ein optionaler Komfort-Layer. + +--- + +## Programm 1: `descro` (Escrow Program) + +### State + +```rust +#[account] +pub struct EscrowAccount { + pub seller: Pubkey, + pub buyer: Pubkey, + pub amount: u64, + pub dispute_resolver: Option, + pub state: EscrowState, + pub bump: u8, + pub vault_bump: u8, + pub escrow_id: u64, + pub dispute_raised_at: Option, // gesetzt wenn dispute() aufgerufen wird +} + +pub enum EscrowState { + AwaitingDeposit, // Seller hat erstellt, Buyer noch nicht gezahlt + Active, // SOL im Vault, Ware unterwegs + Disputed, // Streit offen + Complete, + Cancelled, +} +``` + +**PDAs:** +``` +EscrowAccount: ["escrow", seller, escrow_id_le] +Vault: ["vault", escrow_account_pubkey] +``` + +### Instructions + +#### `create_escrow(amount, dispute_resolver, escrow_id)` — bestehend +``` +Signer: Seller +State: AwaitingDeposit +Zweck: Direktnutzung ohne solisting — Seller initiiert manuell +``` + +#### `create_escrow_prefunded(seller, buyer, amount, dispute_resolver, escrow_id)` — neu +``` +Signer: Seller (zahlt Rent, propagiert via CPI von solisting) +Precond: Vault-PDA hat bereits ≥ amount Lamports +State: Active (kein AwaitingDeposit) +Zweck: Wird von solisting via CPI aufgerufen NACHDEM OrderVault → EscrowVault transferiert wurde + Vollständig permissionless — descro prüft nur ob Vault funded ist +``` + +Keine Kopplung an solisting. Jeder kann `create_escrow_prefunded` aufrufen solange der Vault-PDA die Funds hat. + +#### `deposit()` — bestehend +``` +Signer: Buyer +State: AwaitingDeposit → Active +``` + +#### `complete()` — bestehend +``` +Signer: Buyer +State: Active → Complete +Aktion: Vault → Seller +``` + +#### `dispute()` — modifiziert +``` +Signer: Buyer oder Seller +State: Active → Disputed +Neu: escrow.dispute_raised_at = Clock::get()?.unix_timestamp +``` + +#### `resolve(winner)` — bestehend +``` +Signer: dispute_resolver (Wallet ODER Program via CPI) +State: Disputed → Complete +Side: CPI → Registry.update_stats() +``` + +#### `cancel()` — bestehend +``` +Signer: Seller +State: AwaitingDeposit → Cancelled +``` + +#### `emergency_resolve(winner)` — neu +``` +Signer: Buyer UND Seller (beide müssen signieren) +State: Disputed → Complete +Guards: dispute_raised_at + DISPUTE_TIMEOUT (14 Tage) < now +Zweck: Wenn Resolver verschwindet nach Commitment — beide Parteien können sich + einigen ohne Resolver. Requires echten bilateralen Consent. +``` + +--- + +## Programm 2: `descro_ext_resolvers` (Resolver Registry) + +Weitgehend unverändert. Einzige Ergänzung: `acceptance_policy` Feld. + +```rust +#[account] +pub struct ResolverEntry { + pub authority: Pubkey, + pub resolver_type: ResolverType, + pub acceptance_policy: AcceptancePolicy, // neu + pub name: String, + pub description: String, + pub fee_bps: u16, + pub fee_recipient: Pubkey, + pub metadata_uri: String, + pub total_resolved: u64, + pub ruled_for_buyer: u64, + pub ruled_for_seller: u64, + pub registered_at: i64, +} + +pub enum AcceptancePolicy { + Open, // Jeder kann diesen Resolver nutzen, keine Signatur bei Creation + SignatureGated, // Resolver muss bei accept_order() co-signieren + ProgramGated, // Resolver ist ein Program mit eigener validate()-Logik via CPI +} +``` + +### Resolver Signature Mechanics + +Solana-Transaktionen haben binäre Signaturen — es gibt keine konditionalen `Signer<>` Constraints. Die Lösung: Der Resolver-Account wird als `AccountInfo<'info>` (nicht `Signer<'info>`) deklariert, und die Policy bestimmt ob `is_signer` geprüft wird: + +```rust +pub struct AcceptOrder<'info> { + /// CHECK: Signiert nur wenn Policy == SignatureGated + pub resolver: AccountInfo<'info>, + pub resolver_entry: Option>, +} + +// Im Handler: +let policy = ctx.accounts.resolver_entry + .as_ref() + .map(|e| e.acceptance_policy) + .unwrap_or(AcceptancePolicy::SignatureGated); // kein Registry-Eintrag → muss signieren + +match policy { + Open => {} + SignatureGated => require!(resolver.is_signer, SolistingError::ResolverSignatureRequired), + ProgramGated => { /* CPI zu resolver_program.accept_escrow() */ } +} +``` + +**Policy-Bedeutung:** + +| Policy | Mechanismus | Typischer Use Case | +|---|---|---| +| `Open` | Kein Signature-Check | JuryDAO, MAD — permissionless Programs | +| `SignatureGated` | `resolver.is_signer == true` | Zentrale Plattform, Treuhandservice, Privatperson | +| `ProgramGated` | CPI zu `resolver.validate_escrow()` | Algorithmic, Multisig | +| _(kein Eintrag)_ | Default: `SignatureGated` | Privatperson ohne Registry-Eintrag | + +**Das Darknet-Problem ist gelöst:** Ein `SignatureGated` Resolver (z.B. eBay-Äquivalent) co-signiert `accept_order()` nur für Kunden die sie kennen. Trägt jemand fremdes eBay als Resolver ein ohne ihr Consent → der `accept_order()` Call schlägt fehl weil eBay's Backend nicht signiert. Der Escrow wird gar nicht erst erstellt. + +--- + +## Programm 3: `solisting` (Listing + Order Program) + +### State + +```rust +#[account] +pub struct ListingAccount { + pub seller: Pubkey, + pub price: u64, // Lamports — fix vom Seller gesetzt + pub quantity: u32, + pub resolver: Pubkey, // bevorzugter Resolver + pub metadata_uri: String, // IPFS/Arweave → Titel, Bilder, Versandbedingungen, Beschreibung + pub listing_id: u64, + pub state: ListingState, + pub bump: u8, +} + +pub enum ListingState { Active, Closed } + +#[account] +pub struct OrderAccount { + pub listing: Pubkey, + pub buyer: Pubkey, + pub seller: Pubkey, + pub resolver: Pubkey, + pub amount: u64, + pub escrow_id: u64, // vorher determiniert, für PDA-Berechnung + pub state: OrderState, + pub order_id: u64, + pub created_at: i64, // für cancel_order Timeout + pub bump: u8, + pub vault_bump: u8, +} + +pub enum OrderState { AwaitingSellerAccept, Accepted, Rejected } +``` + +**PDAs:** +``` +Listing: ["listing", seller, listing_id_le] +Order: ["order", listing, buyer, order_id_le] +OrderVault: ["order_vault", order] +``` + +### Instructions + +#### `create_listing(price, quantity, resolver, metadata_uri, listing_id)` +``` +Signer: Seller +Erstellt: ListingAccount +``` + +#### `update_listing(price, quantity, metadata_uri)` +``` +Signer: Seller +Guards: state == Active + keine offenen Orders (quantity guard reicht als Proxy) +``` + +#### `close_listing()` +``` +Signer: Seller +Guards: Keine offenen OrderAccounts gegen dieses Listing +``` + +#### `create_order(listing_pda, order_id, escrow_id)` +``` +Signer: Buyer +Aktion: SOL (listing.price) → OrderVault +State: AwaitingSellerAccept +Guards: listing.state == Active + listing.quantity > 0 +Amount: Aus listing.price gelesen — kein user-supplied amount (verhindert Manipulation) +``` + +`escrow_id` wird vom Buyer-Client beim Aufruf festgelegt (z.B. random u64). Der spätere EscrowVault-PDA ist damit von Anfang an berechenbar — `accept_order` kann Vault-PDA aus `(seller, escrow_id)` ableiten ohne dass der EscrowAccount schon existiert. Bei Kollision (extrem unwahrscheinlich bei random u64) schlägt `create_escrow_prefunded` mit PDA-Already-In-Use fehl — Buyer wählt einfach eine neue ID. + +#### `accept_order()` +``` +Signer: Seller [+ Resolver falls SignatureGated] +Aktion: + 1. Resolver Policy prüfen (is_signer oder CPI) + 2. EscrowVault-PDA Adresse berechnen: + escrow_pda = find_pda(["escrow", seller, escrow_id], DESCRO_PROGRAM_ID) + vault_pda = find_pda(["vault", escrow_pda], DESCRO_PROGRAM_ID) + 3. System transfer: OrderVault → vault_pda (Lamports landen auf nicht-existentem PDA) + 4. CPI → descro.create_escrow_prefunded(seller, buyer, amount, resolver, escrow_id) + descro erstellt EscrowAccount, verifiziert Vault-Balance ≥ amount, State = Active + 5. listing.quantity -= 1 + 6. OrderAccount schließen (rent → seller) +``` + +**Kopplung:** `solisting` kennt die Program-ID von `descro` (als Konfigurationsparameter, nicht hardcoded in `descro`). `descro` kennt `solisting` nicht. + +#### `reject_order()` +``` +Signer: Seller +Aktion: OrderVault → Buyer, OrderAccount schließen +``` + +#### `cancel_order()` +``` +Signer: Buyer +Guards: state == AwaitingSellerAccept + Clock::get()?.unix_timestamp > order.created_at + ORDER_TIMEOUT (z.B. 3 Tage) +Aktion: OrderVault → Buyer, OrderAccount schließen +Zweck: Verhindert SOL-Lockup wenn Seller nie reagiert +``` + +--- + +## Vollständiger Flow: End-to-End + +### Normalfall (kein Dispute) + +``` +SELLER BUYER RESOLVER + │ │ │ + │ create_listing() │ │ + │ price=1 SOL, qty=5 │ │ + │ resolver=eBay-Pubkey │ │ + │────────────────────────▶ Chain │ + │ │ │ + │ │ [sieht Listing] │ + │ │ create_order() │ + │ │ [1 SOL → OrderVault] │ + │ │────────────────────────▶ Chain + │ │ │ + │ [Event: neue Order] │ │ + │ [prüft: Ware verfügbar?] │ │ + │ │ │ + ├─ accept_order() ─────────────────────────────────── ┤ + │ Seller signiert │ + │ Resolver co-signiert (falls SignatureGated) │ + │ → OrderVault → EscrowVault │ + │ → CPI: descro.create_escrow_prefunded() │ + │────────────────────────▶ Chain │ + │ │ │ + │ [versendet Ware] │ │ + │ │ │ + │ │ [Paket erhalten ✓] │ + │ │ complete() │ + │ │────────────────────────▶ Chain + │ │ │ + │ [1 SOL im Wallet] │ │ +``` + +### Dispute-Fall + +``` + │ │ dispute() │ + │ │────────────────────────▶ Chain + │ │ dispute_raised_at = now │ + │ │ │ + │ │ [Resolver prüft: Tracking, Fotos, Beweise] + │ │ │ + │ │ resolve(Buyer) │ + │ │◀────────────────────────┤ + │ │ CPI → Registry.update_stats() + │ │────────────────────────▶ Chain +``` + +### Resolver-Ausfall nach Commitment (Notfall) + +``` + [14 Tage vergangen, kein resolve()] + + Seller UND Buyer │ + signieren gemeinsam: │ + emergency_resolve(winner) │ + ─────────────────────────▶ Chain + [SOL → winner, fertig] +``` + +### Direktnutzung ohne solisting (dezentraler Client) + +``` + Buyer kennt Seller direkt, kein Listing nötig: + + Seller: create_escrow(buyer, resolver, amount, escrow_id) + Buyer: deposit() + ... normaler Flow ... +``` + +--- + +## Signing-Matrix + +| Instruction | Seller | Buyer | Resolver | Notes | +|---|---|---|---|---| +| `create_listing` | ✓ | | | | +| `create_order` | | ✓ | | SOL in OrderVault | +| `accept_order` | ✓ | | cond. | Resolver nur wenn SignatureGated | +| `reject_order` | ✓ | | | SOL zurück an Buyer | +| `cancel_order` | | ✓ | | Nur nach Timeout | +| `create_escrow` | ✓ | | | Direkte Nutzung | +| `create_escrow_prefunded` | ✓ | | | Via CPI von solisting | +| `deposit` | | ✓ | | Direkte Nutzung | +| `complete` | | ✓ | | | +| `dispute` | ✓/✓ | ✓/✓ | | Einer von beiden | +| `resolve` | | | ✓ | Wallet oder Program via CPI | +| `emergency_resolve` | ✓ | ✓ | | Beide müssen signieren | + +--- + +## Was diese Programme bewusst NICHT definieren + +- **`descro`:** Listing-Format, Matching-Logik, wer solisting ist, interne Resolver-Logik +- **`solisting`:** Wie der Resolver den Dispute löst, Identity/KYC, Shipping-Verifizierung +- **Beide:** Privacy, USDC-Support (spätere Phase), EU-Compliance (Application Layer) + +--- + +## Offene Fragen (spätere Phasen) + +- **Mehrere simultane Orders auf letztes Item:** Seller kann nur eine accepten. Die anderen warten auf `cancel_order` nach Timeout. Verbesserung möglich: Seller kann `reject_order` auf alle anderen aktiv aufrufen sobald Lager leer. +- **Listing-Preis in USDC:** Erfordert SPL Token Integration in beiden Programmen. +- **Shipping-Adresse:** Muss off-chain encrypted übermittelt werden (on-chain wäre Privacy-Desaster). Nur für Seller sichtbar, z.B. via Buyer's Public Key verschlüsselt. +- **Events / Indexing:** `emit!()` in allen State-ändernden Instructions damit Indexer kein vollständiges Chain-Scanning brauchen. +- **Listing-Referenz im EscrowAccount:** Aktuell hat `descro` kein `listing_pda` Feld. Für Nachvollziehbarkeit könnte es Optional aufgenommen werden — `descro` validiert es aber nicht.