solisting rework

This commit is contained in:
thesn10
2026-05-25 00:12:51 +02:00
parent 3c663b12e4
commit 4a7af8dc37
2 changed files with 1120 additions and 1282 deletions

View 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