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-Logiksolisting: 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_ordernach Timeout. Verbesserung möglich: Seller kannreject_orderauf 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
descrokeinlisting_pdaFeld. Für Nachvollziehbarkeit könnte es Optional aufgenommen werden —descrovalidiert es aber nicht.