Files
descro/docs/superpowers/specs/2026-05-19-descro-flow-design.md
2026-05-19 17:04:45 +02:00

15 KiB

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

#[account]
pub struct EscrowAccount {
    pub seller: Pubkey,
    pub buyer: Pubkey,
    pub amount: u64,
    pub dispute_resolver: Option<Pubkey>,
    pub state: EscrowState,
    pub bump: u8,
    pub vault_bump: u8,
    pub escrow_id: u64,
    pub dispute_raised_at: Option<i64>,   // 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.

#[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:

pub struct AcceptOrder<'info> {
    /// CHECK: Signiert nur wenn Policy == SignatureGated
    pub resolver: AccountInfo<'info>,
    pub resolver_entry: Option<Account<'info, ResolverEntry>>,
}

// 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

#[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.