solisting rework
This commit is contained in:
481
docs/superpowers/plans/2026-05-19-descro-buyer-flow.md
Normal file
481
docs/superpowers/plans/2026-05-19-descro-buyer-flow.md
Normal file
@@ -0,0 +1,481 @@
|
||||
# Descro: Buyer-Initiated Escrow Flow
|
||||
|
||||
> **Prerequisite for `solisting`.** The `solisting` plan depends on these instructions existing before `accept_order` and `create_order` can be implemented.
|
||||
|
||||
**Goal:** Extend the `descro` program with a parallel buyer-initiated escrow flow. The existing seller-initiated flow (`create_escrow` → `deposit` → `Active`) is unchanged. The new flow allows a buyer to atomically create and fund an escrow in a single transaction, landing in `AwaitingSellerConfirm` state, after which the seller confirms to make it `Active`.
|
||||
|
||||
**Why:** `solisting` needs to CPI into descro at `create_order` time (buyer signs) to lock funds before the seller has accepted. This removes the need for an intermediate solisting vault — the funds live in descro's vault from the start.
|
||||
|
||||
---
|
||||
|
||||
## State Machine After This Change
|
||||
|
||||
```
|
||||
Seller-initiated (unchanged):
|
||||
create_escrow (seller) → AwaitingDeposit
|
||||
deposit (buyer) → Active
|
||||
cancel (any) ← from AwaitingDeposit
|
||||
|
||||
Buyer-initiated (new):
|
||||
buyer_create_escrow (buyer) → AwaitingSellerConfirm [creates + funds vault atomically]
|
||||
seller_confirm (seller) → Active [AcceptancePolicy checked here]
|
||||
cancel (any) ← from AwaitingSellerConfirm
|
||||
|
||||
From Active (unchanged):
|
||||
complete → Completed
|
||||
dispute → Disputed → resolve → Completed/Refunded
|
||||
```
|
||||
|
||||
**Cancel is now symmetric:** either buyer or seller can cancel from either pre-Active state. Vault funds (if any) always return to buyer. EscrowAccount rent goes to the canceller.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | Path |
|
||||
|---|---|
|
||||
| Modify | `programs/descro/src/state.rs` |
|
||||
| Create | `programs/descro/src/instructions/buyer_create_escrow.rs` |
|
||||
| Create | `programs/descro/src/instructions/seller_confirm.rs` |
|
||||
| Modify | `programs/descro/src/instructions/cancel.rs` |
|
||||
| Modify | `programs/descro/src/lib.rs` |
|
||||
| Delete | `programs/descro/src/instructions/create_escrow_prefunded.rs` |
|
||||
| Modify | `programs/descro/src/instructions.rs` (or equivalent module file) |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `AwaitingSellerConfirm` to `EscrowState`
|
||||
|
||||
**File:** `programs/descro/src/state.rs`
|
||||
|
||||
- [ ] **Step 1: Add the new variant**
|
||||
|
||||
```rust
|
||||
#[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq, InitSpace, Debug)]
|
||||
pub enum EscrowState {
|
||||
AwaitingDeposit,
|
||||
AwaitingSellerConfirm, // new: buyer created + funded, waiting for seller
|
||||
Active,
|
||||
Disputed,
|
||||
Complete,
|
||||
Cancelled,
|
||||
}
|
||||
```
|
||||
|
||||
No other state changes needed — `EscrowAccount` struct is unchanged.
|
||||
|
||||
- [ ] **Step 2: Build to verify no regressions**
|
||||
|
||||
```bash
|
||||
cargo build-sbf --manifest-path programs/descro/Cargo.toml 2>&1 | tail -5
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run existing tests**
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -10
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `buyer_create_escrow` — Atomic Create + Fund
|
||||
|
||||
**File:** `programs/descro/src/instructions/buyer_create_escrow.rs`
|
||||
|
||||
The buyer creates the `EscrowAccount` PDA (paying rent) and transfers `amount` SOL into the vault in the same transaction. State lands at `AwaitingSellerConfirm`.
|
||||
|
||||
The EscrowAccount uses the same PDA seeds as seller-initiated: `["escrow", seller_pubkey, escrow_id_le_bytes]`. The seller is passed as an `UncheckedAccount` — they don't need to sign at creation time.
|
||||
|
||||
- [ ] **Step 1: Write the instruction**
|
||||
|
||||
```rust
|
||||
use anchor_lang::prelude::*;
|
||||
use anchor_lang::system_program::{self, Transfer};
|
||||
use crate::state::{EscrowAccount, EscrowState};
|
||||
use crate::error::EscrowError;
|
||||
|
||||
#[derive(Accounts)]
|
||||
#[instruction(amount: u64, dispute_resolver: Option<Pubkey>, escrow_id: u64)]
|
||||
pub struct BuyerCreateEscrow<'info> {
|
||||
#[account(mut)]
|
||||
pub buyer: Signer<'info>,
|
||||
|
||||
/// CHECK: Seller pubkey used as PDA seed — no signature needed at creation
|
||||
pub seller: UncheckedAccount<'info>,
|
||||
|
||||
#[account(
|
||||
init,
|
||||
payer = buyer,
|
||||
space = 8 + EscrowAccount::INIT_SPACE,
|
||||
seeds = [b"escrow", seller.key().as_ref(), &escrow_id.to_le_bytes()],
|
||||
bump
|
||||
)]
|
||||
pub escrow_account: Account<'info, EscrowAccount>,
|
||||
|
||||
/// CHECK: System-owned vault PDA; receives escrowed SOL
|
||||
#[account(
|
||||
mut,
|
||||
seeds = [b"vault", escrow_account.key().as_ref()],
|
||||
bump
|
||||
)]
|
||||
pub vault: UncheckedAccount<'info>,
|
||||
|
||||
pub system_program: Program<'info, System>,
|
||||
}
|
||||
|
||||
pub fn handler(
|
||||
ctx: Context<BuyerCreateEscrow>,
|
||||
amount: u64,
|
||||
dispute_resolver: Option<Pubkey>,
|
||||
escrow_id: u64,
|
||||
) -> Result<()> {
|
||||
let rent_min = Rent::get()?.minimum_balance(0);
|
||||
require!(amount >= rent_min, EscrowError::AmountBelowRentMinimum);
|
||||
|
||||
system_program::transfer(
|
||||
CpiContext::new(
|
||||
ctx.accounts.system_program.to_account_info(),
|
||||
Transfer {
|
||||
from: ctx.accounts.buyer.to_account_info(),
|
||||
to: ctx.accounts.vault.to_account_info(),
|
||||
},
|
||||
),
|
||||
amount,
|
||||
)?;
|
||||
|
||||
let escrow = &mut ctx.accounts.escrow_account;
|
||||
escrow.seller = ctx.accounts.seller.key();
|
||||
escrow.buyer = ctx.accounts.buyer.key();
|
||||
escrow.amount = amount;
|
||||
escrow.dispute_resolver = dispute_resolver;
|
||||
escrow.state = EscrowState::AwaitingSellerConfirm;
|
||||
escrow.bump = ctx.bumps.escrow_account;
|
||||
escrow.vault_bump = ctx.bumps.vault;
|
||||
escrow.escrow_id = escrow_id;
|
||||
escrow.dispute_raised_at = None;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register in `lib.rs`**
|
||||
|
||||
Add to the `#[program]` block:
|
||||
|
||||
```rust
|
||||
pub fn buyer_create_escrow(
|
||||
ctx: Context<BuyerCreateEscrow>,
|
||||
amount: u64,
|
||||
dispute_resolver: Option<Pubkey>,
|
||||
escrow_id: u64,
|
||||
) -> Result<()> {
|
||||
buyer_create_escrow::handler(ctx, amount, dispute_resolver, escrow_id)
|
||||
}
|
||||
```
|
||||
|
||||
And add to the `cpi::accounts` module for CPI callers (solisting needs this):
|
||||
|
||||
```rust
|
||||
// In the cpi module (auto-generated by Anchor from the accounts struct,
|
||||
// but verify the IDL exports BuyerCreateEscrow accounts correctly after build)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write test**
|
||||
|
||||
In `programs/descro/tests/` (new file `test_buyer_flow.rs` or add to existing):
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn buyer_can_create_and_fund_escrow() {
|
||||
// setup() from common
|
||||
// Call buyer_create_escrow with amount = 1 SOL
|
||||
// Assert: escrow exists, state == AwaitingSellerConfirm
|
||||
// Assert: vault lamports == amount
|
||||
// Assert: escrow.buyer == buyer, escrow.seller == seller
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buyer_create_escrow_fails_below_rent_minimum() {
|
||||
// amount = 0 → should fail with AmountBelowRentMinimum
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build and test**
|
||||
|
||||
```bash
|
||||
cargo build-sbf --manifest-path programs/descro/Cargo.toml && \
|
||||
cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -15
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `seller_confirm` — Confirm Buyer-Initiated Escrow
|
||||
|
||||
**File:** `programs/descro/src/instructions/seller_confirm.rs`
|
||||
|
||||
The seller signs to move `AwaitingSellerConfirm → Active`. The `AcceptancePolicy` check lives here — descro is the single point of truth for resolver policy, not solisting.
|
||||
|
||||
- [ ] **Step 1: Write the instruction**
|
||||
|
||||
```rust
|
||||
use anchor_lang::prelude::*;
|
||||
use crate::state::{EscrowAccount, EscrowState};
|
||||
use crate::error::EscrowError;
|
||||
|
||||
#[derive(Accounts)]
|
||||
pub struct SellerConfirm<'info> {
|
||||
pub seller: Signer<'info>,
|
||||
|
||||
/// CHECK: Resolver — may need to co-sign if AcceptancePolicy is SignatureGated
|
||||
pub resolver: AccountInfo<'info>,
|
||||
|
||||
#[account(
|
||||
mut,
|
||||
seeds = [b"escrow", seller.key().as_ref(), &escrow_account.escrow_id.to_le_bytes()],
|
||||
bump = escrow_account.bump,
|
||||
constraint = seller.key() == escrow_account.seller @ EscrowError::Unauthorized,
|
||||
constraint = escrow_account.state == EscrowState::AwaitingSellerConfirm @ EscrowError::InvalidState,
|
||||
)]
|
||||
pub escrow_account: Account<'info, EscrowAccount>,
|
||||
|
||||
/// CHECK: Optional resolver registry entry — read to determine AcceptancePolicy
|
||||
pub resolver_entry: UncheckedAccount<'info>,
|
||||
}
|
||||
|
||||
pub fn handler(ctx: Context<SellerConfirm>) -> Result<()> {
|
||||
// AcceptancePolicy check — only if resolver is set on the escrow
|
||||
if ctx.accounts.escrow_account.dispute_resolver.is_some()
|
||||
&& !ctx.accounts.resolver_entry.data_is_empty()
|
||||
{
|
||||
let entry_data = ctx.accounts.resolver_entry.try_borrow_data()?;
|
||||
let entry = descro_ext_resolvers::state::ResolverEntry::try_deserialize(
|
||||
&mut entry_data.as_ref(),
|
||||
)?;
|
||||
match entry.acceptance_policy {
|
||||
descro_ext_resolvers::state::AcceptancePolicy::Open => {}
|
||||
descro_ext_resolvers::state::AcceptancePolicy::SignatureGated => {
|
||||
require!(
|
||||
ctx.accounts.resolver.is_signer,
|
||||
EscrowError::ResolverSignatureRequired
|
||||
);
|
||||
}
|
||||
descro_ext_resolvers::state::AcceptancePolicy::ProgramGated => {
|
||||
// Future: CPI to resolver program's accept_escrow instruction
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.accounts.escrow_account.state = EscrowState::Active;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** This requires adding `descro_ext_resolvers` as a dependency to `descro`'s `Cargo.toml` (without `cpi` feature — read-only access to types). Add `descro_ext_resolvers = { path = "../descro_ext_resolvers" }` to `[dependencies]`. Also requires the `AcceptancePolicy` prerequisite from the solisting plan to be implemented first.
|
||||
|
||||
> **Note:** Add `ResolverSignatureRequired` to `EscrowError` if not already present.
|
||||
|
||||
- [ ] **Step 2: Register in `lib.rs`**
|
||||
|
||||
```rust
|
||||
pub fn seller_confirm(ctx: Context<SellerConfirm>) -> Result<()> {
|
||||
seller_confirm::handler(ctx)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write tests**
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn seller_can_confirm_buyer_escrow() {
|
||||
// buyer_create_escrow → AwaitingSellerConfirm
|
||||
// seller_confirm → Active
|
||||
// Assert: state == Active
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seller_confirm_fails_from_wrong_state() {
|
||||
// create_escrow (seller-initiated) → AwaitingDeposit
|
||||
// seller_confirm → must fail (wrong state)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_gated_resolver_requires_co_sign() {
|
||||
// Register resolver with AcceptancePolicy::SignatureGated
|
||||
// buyer_create_escrow → AwaitingSellerConfirm
|
||||
// seller_confirm WITHOUT resolver signing → must fail
|
||||
// seller_confirm WITH resolver co-signing → Active
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build and test**
|
||||
|
||||
```bash
|
||||
cargo build-sbf --manifest-path programs/descro/Cargo.toml && \
|
||||
cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -15
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Extend `cancel` — Symmetric Pre-Active Cancellation
|
||||
|
||||
**File:** `programs/descro/src/instructions/cancel.rs`
|
||||
|
||||
**Current behavior:** Seller-only, only from `AwaitingDeposit`, vault is always empty (deposit hasn't happened).
|
||||
|
||||
**New behavior:**
|
||||
- Either buyer or seller can cancel
|
||||
- Works from `AwaitingDeposit` or `AwaitingSellerConfirm`
|
||||
- If vault has funds (always true in `AwaitingSellerConfirm`), drain to buyer first
|
||||
- EscrowAccount rent goes to whoever called cancel (`close = canceller`)
|
||||
|
||||
- [ ] **Step 1: Replace `cancel.rs`**
|
||||
|
||||
```rust
|
||||
use anchor_lang::prelude::*;
|
||||
use anchor_lang::system_program::{self, Transfer};
|
||||
use crate::state::{EscrowAccount, EscrowState};
|
||||
use crate::error::EscrowError;
|
||||
|
||||
#[derive(Accounts)]
|
||||
pub struct Cancel<'info> {
|
||||
#[account(mut)]
|
||||
pub canceller: Signer<'info>,
|
||||
|
||||
/// CHECK: Buyer receives vault refund if funds are present
|
||||
#[account(
|
||||
mut,
|
||||
constraint = buyer.key() == escrow_account.buyer @ EscrowError::Unauthorized,
|
||||
)]
|
||||
pub buyer: UncheckedAccount<'info>,
|
||||
|
||||
#[account(
|
||||
mut,
|
||||
seeds = [b"escrow", escrow_account.seller.as_ref(), &escrow_account.escrow_id.to_le_bytes()],
|
||||
bump = escrow_account.bump,
|
||||
constraint = (
|
||||
canceller.key() == escrow_account.buyer ||
|
||||
canceller.key() == escrow_account.seller
|
||||
) @ EscrowError::Unauthorized,
|
||||
constraint = (
|
||||
escrow_account.state == EscrowState::AwaitingDeposit ||
|
||||
escrow_account.state == EscrowState::AwaitingSellerConfirm
|
||||
) @ EscrowError::InvalidState,
|
||||
close = canceller,
|
||||
)]
|
||||
pub escrow_account: Account<'info, EscrowAccount>,
|
||||
|
||||
/// CHECK: Vault PDA — may hold funds (AwaitingSellerConfirm) or be empty (AwaitingDeposit)
|
||||
#[account(
|
||||
mut,
|
||||
seeds = [b"vault", escrow_account.key().as_ref()],
|
||||
bump = escrow_account.vault_bump,
|
||||
)]
|
||||
pub vault: UncheckedAccount<'info>,
|
||||
|
||||
pub system_program: Program<'info, System>,
|
||||
}
|
||||
|
||||
pub fn handler(ctx: Context<Cancel>) -> Result<()> {
|
||||
let vault_lamports = ctx.accounts.vault.to_account_info().lamports();
|
||||
|
||||
if vault_lamports > 0 {
|
||||
let escrow_key = ctx.accounts.escrow_account.key();
|
||||
let vault_bump = ctx.accounts.escrow_account.vault_bump;
|
||||
|
||||
system_program::transfer(
|
||||
CpiContext::new_with_signer(
|
||||
ctx.accounts.system_program.to_account_info(),
|
||||
Transfer {
|
||||
from: ctx.accounts.vault.to_account_info(),
|
||||
to: ctx.accounts.buyer.to_account_info(),
|
||||
},
|
||||
&[&[b"vault", escrow_key.as_ref(), &[vault_bump]]],
|
||||
),
|
||||
vault_lamports,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
> **Breaking change:** The existing `cancel` instruction had a `seller: Signer` account. It is now `canceller: Signer` with a buyer account added. Fix all existing tests that call cancel.
|
||||
|
||||
- [ ] **Step 2: Update existing tests that use `cancel`**
|
||||
|
||||
Find and update any test that constructs a `Cancel` instruction — the accounts struct has changed (`seller` → `canceller`, new `buyer` account added).
|
||||
|
||||
- [ ] **Step 3: Write new cancel tests**
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn seller_can_cancel_awaiting_deposit() {
|
||||
// create_escrow → AwaitingDeposit
|
||||
// seller calls cancel → EscrowAccount closed
|
||||
// vault was empty → no refund needed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buyer_can_cancel_awaiting_deposit() {
|
||||
// create_escrow → AwaitingDeposit
|
||||
// buyer calls cancel → EscrowAccount closed (seller loses rent — acceptable)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seller_can_cancel_awaiting_seller_confirm() {
|
||||
// buyer_create_escrow → AwaitingSellerConfirm (vault has funds)
|
||||
// seller calls cancel → vault drained to buyer, EscrowAccount closed to seller
|
||||
// Assert: buyer received vault lamports back
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buyer_can_cancel_awaiting_seller_confirm() {
|
||||
// buyer_create_escrow → AwaitingSellerConfirm
|
||||
// buyer calls cancel → vault drained to buyer, EscrowAccount closed to buyer
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cannot_cancel_active_escrow() {
|
||||
// Full flow to Active
|
||||
// cancel → must fail (InvalidState)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build and full test suite**
|
||||
|
||||
```bash
|
||||
cargo build-sbf --manifest-path programs/descro/Cargo.toml && \
|
||||
cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -15
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Cleanup
|
||||
|
||||
- [ ] **Step 1: Delete `create_escrow_prefunded.rs`**
|
||||
|
||||
This instruction was planned but never needed now. Remove the file and its references from `lib.rs` and the instructions module.
|
||||
|
||||
```bash
|
||||
rm programs/descro/src/instructions/create_escrow_prefunded.rs
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rebuild both programs**
|
||||
|
||||
```bash
|
||||
cargo build-sbf --manifest-path programs/descro_ext_resolvers/Cargo.toml && \
|
||||
cargo build-sbf --manifest-path programs/descro/Cargo.toml
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Full test suite**
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path programs/descro_ext_resolvers/Cargo.toml 2>&1 | tail -5
|
||||
cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -5
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add programs/descro/
|
||||
git commit -m "feat(descro): add buyer-initiated escrow flow (AwaitingSellerConfirm + seller_confirm + symmetric cancel)"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user