diff --git a/Cargo.lock b/Cargo.lock index c34102d..795595d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1073,6 +1073,19 @@ dependencies = [ [[package]] name = "descro" version = "0.1.0" +dependencies = [ + "anchor-lang", + "descro_ext_resolvers", + "litesvm", + "solana-keypair", + "solana-message", + "solana-signer", + "solana-transaction", +] + +[[package]] +name = "descro_ext_resolvers" +version = "0.1.0" dependencies = [ "anchor-lang", "litesvm", diff --git a/programs/descro/Cargo.toml b/programs/descro/Cargo.toml index 1de9706..ba60e26 100644 --- a/programs/descro/Cargo.toml +++ b/programs/descro/Cargo.toml @@ -22,6 +22,7 @@ custom-panic = [] [dependencies] anchor-lang = "1.0.2" +descro_ext_resolvers = { path = "../descro_ext_resolvers", features = ["cpi"] } [dev-dependencies] litesvm = "0.10.0" diff --git a/programs/descro/src/instructions/resolve.rs b/programs/descro/src/instructions/resolve.rs index 0819b5b..5377acb 100644 --- a/programs/descro/src/instructions/resolve.rs +++ b/programs/descro/src/instructions/resolve.rs @@ -1,5 +1,6 @@ use anchor_lang::prelude::*; use anchor_lang::solana_program::{program::invoke_signed, system_instruction}; +use descro_ext_resolvers::state::Ruling; use crate::state::{EscrowAccount, EscrowState, Winner}; use crate::error::EscrowError; @@ -23,6 +24,8 @@ pub struct Resolve<'info> { seeds = [b"escrow", escrow_account.seller.as_ref(), &escrow_account.escrow_id.to_le_bytes()], bump = escrow_account.bump, constraint = escrow_account.state == EscrowState::Disputed @ EscrowError::InvalidState, + constraint = escrow_account.dispute_resolver.is_some() @ EscrowError::NoResolverConfigured, + constraint = resolver.key() == escrow_account.dispute_resolver.unwrap() @ EscrowError::UnauthorizedResolver, close = seller, )] pub escrow_account: Account<'info, EscrowAccount>, @@ -35,19 +38,32 @@ pub struct Resolve<'info> { )] pub vault: UncheckedAccount<'info>, + /// CHECK: Registry entry for this resolver; if initialized, stats are updated via CPI + #[account( + mut, + seeds = [b"resolver", escrow_account.dispute_resolver.unwrap_or_default().as_ref()], + bump, + seeds::program = descro_ext_resolvers::ID, + )] + pub resolver_entry: UncheckedAccount<'info>, + + /// CHECK: Escrow program authority PDA — signs the update_stats CPI + #[account( + seeds = [b"escrow_authority"], + bump, + )] + pub escrow_authority: UncheckedAccount<'info>, + + /// CHECK: Registry program account; validated against descro_ext_resolvers::ID in handler + pub registry_program: UncheckedAccount<'info>, + pub system_program: Program<'info, System>, } pub fn handler(ctx: Context, winner: Winner) -> Result<()> { let escrow = &ctx.accounts.escrow_account; - require!(escrow.dispute_resolver.is_some(), EscrowError::NoResolverConfigured); - require!( - ctx.accounts.resolver.key() == escrow.dispute_resolver.unwrap(), - EscrowError::UnauthorizedResolver - ); - - let expected_winner = match winner { + let expected_winner = match &winner { Winner::Buyer => escrow.buyer, Winner::Seller => escrow.seller, }; @@ -75,5 +91,27 @@ pub fn handler(ctx: Context, winner: Winner) -> Result<()> { &[&[b"vault", escrow_key.as_ref(), &[vault_bump]]], )?; + // Update resolver stats if the entry is registered in the registry + if !ctx.accounts.resolver_entry.data_is_empty() + && ctx.accounts.registry_program.key() == descro_ext_resolvers::ID + { + let ruling = match winner { + Winner::Buyer => Ruling::Buyer, + Winner::Seller => Ruling::Seller, + }; + let escrow_authority_bump = ctx.bumps.escrow_authority; + descro_ext_resolvers::cpi::update_stats( + CpiContext::new_with_signer( + descro_ext_resolvers::ID, + descro_ext_resolvers::cpi::accounts::UpdateStats { + escrow_authority: ctx.accounts.escrow_authority.to_account_info(), + resolver_entry: ctx.accounts.resolver_entry.to_account_info(), + }, + &[&[b"escrow_authority", &[escrow_authority_bump]]], + ), + ruling, + )?; + } + Ok(()) } diff --git a/programs/descro/tests/common/mod.rs b/programs/descro/tests/common/mod.rs index 4d4a7eb..1563e16 100644 --- a/programs/descro/tests/common/mod.rs +++ b/programs/descro/tests/common/mod.rs @@ -45,6 +45,18 @@ pub fn vault_pda(escrow: &Pubkey) -> Pubkey { Pubkey::find_program_address(&[b"vault", escrow.as_ref()], &descro::id()).0 } +pub fn escrow_authority_pda() -> Pubkey { + Pubkey::find_program_address(&[b"escrow_authority"], &descro::id()).0 +} + +pub fn resolver_entry_pda(resolver_authority: &Pubkey) -> Pubkey { + Pubkey::find_program_address( + &[b"resolver", resolver_authority.as_ref()], + &descro_ext_resolvers::id(), + ) + .0 +} + pub fn send(svm: &mut LiteSVM, ix: Instruction, payer: &Keypair) { let blockhash = svm.latest_blockhash(); let msg = Message::new_with_blockhash(&[ix], Some(&payer.pubkey()), &blockhash); @@ -128,15 +140,21 @@ pub fn ix_dispute(initiator: &Pubkey, seller: &Pubkey, escrow_id: u64) -> Instru ) } +/// Builds a resolve instruction. Pass the actual registry program ID when testing with a +/// registered resolver; for unregistered resolvers pass `system_program::ID` as +/// `registry_program` (the handler skips the CPI if resolver_entry has no data). pub fn ix_resolve( resolver: &Pubkey, winner_pubkey: &Pubkey, seller: &Pubkey, escrow_id: u64, winner: Winner, + registry_program: Pubkey, ) -> Instruction { let escrow = escrow_pda(seller, escrow_id); let vault = vault_pda(&escrow); + let resolver_entry = resolver_entry_pda(resolver); + let escrow_authority = escrow_authority_pda(); Instruction::new_with_bytes( descro::id(), &descro::instruction::Resolve { winner }.data(), @@ -146,6 +164,9 @@ pub fn ix_resolve( seller: *seller, escrow_account: escrow, vault, + resolver_entry, + escrow_authority, + registry_program, system_program: system_program::ID, } .to_account_metas(None), diff --git a/programs/descro/tests/test_resolve.rs b/programs/descro/tests/test_resolve.rs index 295a07c..0d63b67 100644 --- a/programs/descro/tests/test_resolve.rs +++ b/programs/descro/tests/test_resolve.rs @@ -1,5 +1,6 @@ mod common; use common::*; +use anchor_lang::solana_program::system_program; use solana_keypair::Keypair; use solana_signer::Signer; @@ -33,7 +34,14 @@ fn resolver_can_rule_for_seller() { send( &mut svm, - ix_resolve(&resolver.pubkey(), &seller.pubkey(), &seller.pubkey(), ESCROW_ID, EscrowWinner::Seller), + ix_resolve( + &resolver.pubkey(), + &seller.pubkey(), + &seller.pubkey(), + ESCROW_ID, + EscrowWinner::Seller, + system_program::ID, // no registry + ), &resolver, ); @@ -51,7 +59,14 @@ fn resolver_can_rule_for_buyer() { send( &mut svm, - ix_resolve(&resolver.pubkey(), &buyer.pubkey(), &seller.pubkey(), ESCROW_ID, EscrowWinner::Buyer), + ix_resolve( + &resolver.pubkey(), + &buyer.pubkey(), + &seller.pubkey(), + ESCROW_ID, + EscrowWinner::Buyer, + system_program::ID, // no registry + ), &resolver, ); @@ -73,7 +88,14 @@ fn resolve_without_configured_resolver_fails() { assert!(!try_send( &mut svm, - ix_resolve(&resolver.pubkey(), &seller.pubkey(), &seller.pubkey(), ESCROW_ID, EscrowWinner::Seller), + ix_resolve( + &resolver.pubkey(), + &seller.pubkey(), + &seller.pubkey(), + ESCROW_ID, + EscrowWinner::Seller, + system_program::ID, + ), &resolver, )); } @@ -87,7 +109,14 @@ fn wrong_resolver_fails() { assert!(!try_send( &mut svm, - ix_resolve(&wrong.pubkey(), &seller.pubkey(), &seller.pubkey(), ESCROW_ID, EscrowWinner::Seller), + ix_resolve( + &wrong.pubkey(), + &seller.pubkey(), + &seller.pubkey(), + ESCROW_ID, + EscrowWinner::Seller, + system_program::ID, + ), &wrong, )); } @@ -109,10 +138,16 @@ fn resolve_before_dispute_fails() { ); send(&mut svm, ix_deposit(&buyer.pubkey(), &seller.pubkey(), ESCROW_ID), &buyer); - // State is Active, not Disputed assert!(!try_send( &mut svm, - ix_resolve(&resolver.pubkey(), &seller.pubkey(), &seller.pubkey(), ESCROW_ID, EscrowWinner::Seller), + ix_resolve( + &resolver.pubkey(), + &seller.pubkey(), + &seller.pubkey(), + ESCROW_ID, + EscrowWinner::Seller, + system_program::ID, + ), &resolver, )); } diff --git a/programs/descro/tests/test_resolve_with_registry.rs b/programs/descro/tests/test_resolve_with_registry.rs new file mode 100644 index 0000000..6129417 --- /dev/null +++ b/programs/descro/tests/test_resolve_with_registry.rs @@ -0,0 +1,177 @@ +/// Integration test: resolve() calls update_stats() via CPI when the resolver +/// has a registered entry in the Resolver Registry. +mod common; +use common::*; +use anchor_lang::InstructionData; +use anchor_lang::ToAccountMetas; +use anchor_lang::solana_program::{instruction::Instruction, system_program}; +use descro_ext_resolvers::{ResolverType, ResolverEntry}; +use anchor_lang::AccountDeserialize; +use litesvm::LiteSVM; +use solana_keypair::Keypair; +use solana_signer::Signer; + +fn setup_both_programs() -> (LiteSVM, Keypair, Keypair, Keypair) { + let mut svm = LiteSVM::new(); + + let escrow_bytes = include_bytes!("../../../target/deploy/descro.so"); + svm.add_program(descro::id(), escrow_bytes).unwrap(); + + let registry_bytes = include_bytes!("../../../target/deploy/descro_ext_resolvers.so"); + svm.add_program(descro_ext_resolvers::id(), registry_bytes).unwrap(); + + let seller = Keypair::new(); + let buyer = Keypair::new(); + let resolver = Keypair::new(); + + svm.airdrop(&seller.pubkey(), 10_000_000_000).unwrap(); + svm.airdrop(&buyer.pubkey(), 10_000_000_000).unwrap(); + svm.airdrop(&resolver.pubkey(), 5_000_000_000).unwrap(); + + (svm, seller, buyer, resolver) +} + +fn ix_register_resolver(authority: &Pubkey) -> Instruction { + let entry = resolver_entry_pda(authority); + Instruction::new_with_bytes( + descro_ext_resolvers::id(), + &descro_ext_resolvers::instruction::RegisterResolver { + resolver_type: ResolverType::CentralAuthority, + name: "Test Resolver".to_string(), + description: "Integration test resolver".to_string(), + fee_bps: 100, + fee_recipient: *authority, + metadata_uri: "ipfs://test".to_string(), + } + .data(), + descro_ext_resolvers::accounts::RegisterResolver { + authority: *authority, + resolver_entry: entry, + system_program: system_program::ID, + } + .to_account_metas(None), + ) +} + +fn read_resolver_entry(svm: &LiteSVM, authority: &Pubkey) -> ResolverEntry { + let pda = resolver_entry_pda(authority); + let account = svm.get_account(&pda).expect("resolver entry not found"); + ResolverEntry::try_deserialize(&mut account.data.as_slice()).unwrap() +} + +#[test] +fn resolve_updates_stats_when_resolver_registered() { + let (mut svm, seller, buyer, resolver) = setup_both_programs(); + + // Register the resolver in the Registry + send(&mut svm, ix_register_resolver(&resolver.pubkey()), &resolver); + + // Full escrow flow + send( + &mut svm, + ix_create_escrow( + &seller.pubkey(), + &buyer.pubkey(), + AMOUNT, + Some(resolver.pubkey()), + ESCROW_ID, + ), + &seller, + ); + send(&mut svm, ix_deposit(&buyer.pubkey(), &seller.pubkey(), ESCROW_ID), &buyer); + send(&mut svm, ix_dispute(&buyer.pubkey(), &seller.pubkey(), ESCROW_ID), &buyer); + + // Resolve with registry program provided + send( + &mut svm, + ix_resolve( + &resolver.pubkey(), + &seller.pubkey(), + &seller.pubkey(), + ESCROW_ID, + EscrowWinner::Seller, + descro_ext_resolvers::id(), // pass registry program → triggers CPI + ), + &resolver, + ); + + let entry = read_resolver_entry(&svm, &resolver.pubkey()); + assert_eq!(entry.total_resolved, 1); + assert_eq!(entry.ruled_for_seller, 1); + assert_eq!(entry.ruled_for_buyer, 0); +} + +#[test] +fn resolve_skips_stats_when_resolver_not_registered() { + let (mut svm, seller, buyer, resolver) = setup_both_programs(); + + // Do NOT register resolver — entry does not exist + send( + &mut svm, + ix_create_escrow( + &seller.pubkey(), + &buyer.pubkey(), + AMOUNT, + Some(resolver.pubkey()), + ESCROW_ID, + ), + &seller, + ); + send(&mut svm, ix_deposit(&buyer.pubkey(), &seller.pubkey(), ESCROW_ID), &buyer); + send(&mut svm, ix_dispute(&buyer.pubkey(), &seller.pubkey(), ESCROW_ID), &buyer); + + // Resolve without passing registry program — CPI skipped + send( + &mut svm, + ix_resolve( + &resolver.pubkey(), + &seller.pubkey(), + &seller.pubkey(), + ESCROW_ID, + EscrowWinner::Seller, + system_program::ID, // no registry → CPI skipped + ), + &resolver, + ); + + // Escrow closed, resolver entry still absent + assert!(svm.get_account(&escrow_pda(&seller.pubkey(), ESCROW_ID)).is_none()); + assert!(svm.get_account(&resolver_entry_pda(&resolver.pubkey())).is_none()); +} + +#[test] +fn resolve_buyer_wins_increments_ruled_for_buyer() { + let (mut svm, seller, buyer, resolver) = setup_both_programs(); + + send(&mut svm, ix_register_resolver(&resolver.pubkey()), &resolver); + send( + &mut svm, + ix_create_escrow( + &seller.pubkey(), + &buyer.pubkey(), + AMOUNT, + Some(resolver.pubkey()), + ESCROW_ID, + ), + &seller, + ); + send(&mut svm, ix_deposit(&buyer.pubkey(), &seller.pubkey(), ESCROW_ID), &buyer); + send(&mut svm, ix_dispute(&buyer.pubkey(), &seller.pubkey(), ESCROW_ID), &buyer); + send( + &mut svm, + ix_resolve( + &resolver.pubkey(), + &buyer.pubkey(), + &seller.pubkey(), + ESCROW_ID, + EscrowWinner::Buyer, + descro_ext_resolvers::id(), + ), + &resolver, + ); + + let entry = read_resolver_entry(&svm, &resolver.pubkey()); + assert_eq!(entry.total_resolved, 1); + assert_eq!(entry.ruled_for_buyer, 1); + assert_eq!(entry.ruled_for_seller, 0); +} diff --git a/programs/descro_ext_resolvers/Cargo.toml b/programs/descro_ext_resolvers/Cargo.toml new file mode 100644 index 0000000..62e842b --- /dev/null +++ b/programs/descro_ext_resolvers/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "descro_ext_resolvers" +version = "0.1.0" +description = "Resolver Registry for the descro escrow protocol" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "descro_ext_resolvers" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build"] +anchor-debug = [] +custom-heap = [] +custom-panic = [] + +[dependencies] +anchor-lang = "1.0.2" + +[dev-dependencies] +litesvm = "0.10.0" +solana-message = "3.0.1" +solana-transaction = "3.0.2" +solana-signer = "3.0.0" +solana-keypair = "3.0.1" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/programs/descro_ext_resolvers/src/error.rs b/programs/descro_ext_resolvers/src/error.rs new file mode 100644 index 0000000..63da921 --- /dev/null +++ b/programs/descro_ext_resolvers/src/error.rs @@ -0,0 +1,13 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum RegistryError { + #[msg("Signer is not the registered authority")] + Unauthorized, + #[msg("Caller is not the authorized escrow program")] + UnauthorizedCaller, + #[msg("Fee basis points must be <= 10000")] + InvalidFeeBps, + #[msg("Name must not be empty")] + EmptyName, +} diff --git a/programs/descro_ext_resolvers/src/instructions.rs b/programs/descro_ext_resolvers/src/instructions.rs new file mode 100644 index 0000000..8d57052 --- /dev/null +++ b/programs/descro_ext_resolvers/src/instructions.rs @@ -0,0 +1,7 @@ +pub mod register; +pub mod update; +pub mod update_stats; + +pub use register::*; +pub use update::*; +pub use update_stats::*; diff --git a/programs/descro_ext_resolvers/src/instructions/register.rs b/programs/descro_ext_resolvers/src/instructions/register.rs new file mode 100644 index 0000000..e3bb4c1 --- /dev/null +++ b/programs/descro_ext_resolvers/src/instructions/register.rs @@ -0,0 +1,47 @@ +use anchor_lang::prelude::*; +use crate::state::{ResolverEntry, ResolverType}; +use crate::error::RegistryError; + +#[derive(Accounts)] +pub struct RegisterResolver<'info> { + #[account(mut)] + pub authority: Signer<'info>, + + #[account( + init, + payer = authority, + space = 8 + ResolverEntry::INIT_SPACE, + seeds = [b"resolver", authority.key().as_ref()], + bump + )] + pub resolver_entry: Account<'info, ResolverEntry>, + + pub system_program: Program<'info, System>, +} + +pub fn handler( + ctx: Context, + resolver_type: ResolverType, + name: String, + description: String, + fee_bps: u16, + fee_recipient: Pubkey, + metadata_uri: String, +) -> Result<()> { + require!(!name.is_empty(), RegistryError::EmptyName); + require!(fee_bps <= 10_000, RegistryError::InvalidFeeBps); + + let entry = &mut ctx.accounts.resolver_entry; + entry.authority = ctx.accounts.authority.key(); + entry.resolver_type = resolver_type; + entry.name = name; + entry.description = description; + entry.fee_bps = fee_bps; + entry.fee_recipient = fee_recipient; + entry.metadata_uri = metadata_uri; + entry.total_resolved = 0; + entry.ruled_for_buyer = 0; + entry.ruled_for_seller = 0; + entry.registered_at = Clock::get()?.unix_timestamp; + Ok(()) +} diff --git a/programs/descro_ext_resolvers/src/instructions/update.rs b/programs/descro_ext_resolvers/src/instructions/update.rs new file mode 100644 index 0000000..a91a0d3 --- /dev/null +++ b/programs/descro_ext_resolvers/src/instructions/update.rs @@ -0,0 +1,34 @@ +use anchor_lang::prelude::*; +use crate::state::ResolverEntry; +use crate::error::RegistryError; + +#[derive(Accounts)] +pub struct UpdateResolver<'info> { + pub authority: Signer<'info>, + + #[account( + mut, + seeds = [b"resolver", authority.key().as_ref()], + bump, + constraint = resolver_entry.authority == authority.key() @ RegistryError::Unauthorized, + )] + pub resolver_entry: Account<'info, ResolverEntry>, +} + +pub fn handler( + ctx: Context, + name: String, + description: String, + fee_bps: u16, + metadata_uri: String, +) -> Result<()> { + require!(!name.is_empty(), RegistryError::EmptyName); + require!(fee_bps <= 10_000, RegistryError::InvalidFeeBps); + + let entry = &mut ctx.accounts.resolver_entry; + entry.name = name; + entry.description = description; + entry.fee_bps = fee_bps; + entry.metadata_uri = metadata_uri; + Ok(()) +} diff --git a/programs/descro_ext_resolvers/src/instructions/update_stats.rs b/programs/descro_ext_resolvers/src/instructions/update_stats.rs new file mode 100644 index 0000000..20e53e0 --- /dev/null +++ b/programs/descro_ext_resolvers/src/instructions/update_stats.rs @@ -0,0 +1,36 @@ +use anchor_lang::prelude::*; +use crate::state::{ResolverEntry, Ruling}; +use crate::error::RegistryError; +use crate::ESCROW_PROGRAM_ID; + +#[derive(Accounts)] +pub struct UpdateStats<'info> { + /// Must be the Escrow Program's authority PDA — only it can sign this. + #[account( + seeds = [b"escrow_authority"], + bump, + seeds::program = ESCROW_PROGRAM_ID, + )] + pub escrow_authority: Signer<'info>, + + #[account(mut)] + pub resolver_entry: Account<'info, ResolverEntry>, +} + +pub fn handler(ctx: Context, ruling: Ruling) -> Result<()> { + // Verify the signer is truly derived from the escrow program (constraint handles this, + // but log an explicit error if somehow reached with a wrong caller) + let escrow_pda = Pubkey::find_program_address(&[b"escrow_authority"], &ESCROW_PROGRAM_ID).0; + require!( + ctx.accounts.escrow_authority.key() == escrow_pda, + RegistryError::UnauthorizedCaller + ); + + let entry = &mut ctx.accounts.resolver_entry; + entry.total_resolved = entry.total_resolved.saturating_add(1); + match ruling { + Ruling::Buyer => entry.ruled_for_buyer = entry.ruled_for_buyer.saturating_add(1), + Ruling::Seller => entry.ruled_for_seller = entry.ruled_for_seller.saturating_add(1), + } + Ok(()) +} diff --git a/programs/descro_ext_resolvers/src/lib.rs b/programs/descro_ext_resolvers/src/lib.rs new file mode 100644 index 0000000..1ec005d --- /dev/null +++ b/programs/descro_ext_resolvers/src/lib.rs @@ -0,0 +1,47 @@ +pub mod error; +pub mod instructions; +pub mod state; + +use anchor_lang::prelude::*; + +pub use error::*; +pub use instructions::*; +pub use state::*; + +// Replace with actual program ID after first deployment +declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS"); + +/// Escrow Program ID — only its PDA may call update_stats. +pub const ESCROW_PROGRAM_ID: Pubkey = + pubkey!("DjVR4EuYV6USMJFfsGZwhZ3y8rtWsmG8EvDY96GTqqi3"); + +#[program] +pub mod descro_ext_resolvers { + use super::*; + + pub fn register_resolver( + ctx: Context, + resolver_type: ResolverType, + name: String, + description: String, + fee_bps: u16, + fee_recipient: Pubkey, + metadata_uri: String, + ) -> Result<()> { + register::handler(ctx, resolver_type, name, description, fee_bps, fee_recipient, metadata_uri) + } + + pub fn update_resolver( + ctx: Context, + name: String, + description: String, + fee_bps: u16, + metadata_uri: String, + ) -> Result<()> { + update::handler(ctx, name, description, fee_bps, metadata_uri) + } + + pub fn update_stats(ctx: Context, ruling: Ruling) -> Result<()> { + update_stats::handler(ctx, ruling) + } +} diff --git a/programs/descro_ext_resolvers/src/state.rs b/programs/descro_ext_resolvers/src/state.rs new file mode 100644 index 0000000..30f5df0 --- /dev/null +++ b/programs/descro_ext_resolvers/src/state.rs @@ -0,0 +1,36 @@ +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct ResolverEntry { + pub authority: Pubkey, + pub resolver_type: ResolverType, + #[max_len(64)] + pub name: String, + #[max_len(256)] + pub description: String, + pub fee_bps: u16, + pub fee_recipient: Pubkey, + #[max_len(256)] + pub metadata_uri: String, + pub total_resolved: u64, + pub ruled_for_buyer: u64, + pub ruled_for_seller: u64, + pub registered_at: i64, +} + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq, InitSpace, Debug)] +pub enum ResolverType { + CentralAuthority, + JuryDAO, + MAD, + Algorithmic, + Multisig, +} + +/// Passed to update_stats; mirrors the Escrow program's Winner enum. +#[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq)] +pub enum Ruling { + Buyer, + Seller, +} diff --git a/programs/descro_ext_resolvers/tests/common/mod.rs b/programs/descro_ext_resolvers/tests/common/mod.rs new file mode 100644 index 0000000..155b504 --- /dev/null +++ b/programs/descro_ext_resolvers/tests/common/mod.rs @@ -0,0 +1,108 @@ +use { + anchor_lang::{ + solana_program::{instruction::Instruction, system_program}, + AccountDeserialize, InstructionData, ToAccountMetas, + }, + descro_ext_resolvers::{ResolverEntry, ResolverType}, + litesvm::LiteSVM, + solana_keypair::Keypair, + solana_message::{Message, VersionedMessage}, + solana_signer::Signer, + solana_transaction::versioned::VersionedTransaction, +}; +pub use anchor_lang::prelude::Pubkey; + +pub fn setup() -> (LiteSVM, Keypair) { + let program_id = descro_ext_resolvers::id(); + let mut svm = LiteSVM::new(); + let bytes = include_bytes!("../../../../target/deploy/descro_ext_resolvers.so"); + svm.add_program(program_id, bytes).unwrap(); + + let authority = Keypair::new(); + svm.airdrop(&authority.pubkey(), 10_000_000_000).unwrap(); + + (svm, authority) +} + +pub fn resolver_entry_pda(authority: &Pubkey) -> Pubkey { + Pubkey::find_program_address( + &[b"resolver", authority.as_ref()], + &descro_ext_resolvers::id(), + ) + .0 +} + +pub fn send(svm: &mut LiteSVM, ix: Instruction, payer: &Keypair) { + let blockhash = svm.latest_blockhash(); + let msg = Message::new_with_blockhash(&[ix], Some(&payer.pubkey()), &blockhash); + let tx = VersionedTransaction::try_new(VersionedMessage::Legacy(msg), &[payer]).unwrap(); + svm.send_transaction(tx).expect("transaction failed"); +} + +pub fn try_send(svm: &mut LiteSVM, ix: Instruction, payer: &Keypair) -> bool { + let blockhash = svm.latest_blockhash(); + let msg = Message::new_with_blockhash(&[ix], Some(&payer.pubkey()), &blockhash); + let tx = VersionedTransaction::try_new(VersionedMessage::Legacy(msg), &[payer]).unwrap(); + svm.send_transaction(tx).is_ok() +} + +pub fn ix_register( + authority: &Pubkey, + resolver_type: ResolverType, + name: &str, + description: &str, + fee_bps: u16, + fee_recipient: Pubkey, + metadata_uri: &str, +) -> Instruction { + let entry = resolver_entry_pda(authority); + Instruction::new_with_bytes( + descro_ext_resolvers::id(), + &descro_ext_resolvers::instruction::RegisterResolver { + resolver_type, + name: name.to_string(), + description: description.to_string(), + fee_bps, + fee_recipient, + metadata_uri: metadata_uri.to_string(), + } + .data(), + descro_ext_resolvers::accounts::RegisterResolver { + authority: *authority, + resolver_entry: entry, + system_program: system_program::ID, + } + .to_account_metas(None), + ) +} + +pub fn ix_update( + authority: &Pubkey, + name: &str, + description: &str, + fee_bps: u16, + metadata_uri: &str, +) -> Instruction { + let entry = resolver_entry_pda(authority); + Instruction::new_with_bytes( + descro_ext_resolvers::id(), + &descro_ext_resolvers::instruction::UpdateResolver { + name: name.to_string(), + description: description.to_string(), + fee_bps, + metadata_uri: metadata_uri.to_string(), + } + .data(), + descro_ext_resolvers::accounts::UpdateResolver { + authority: *authority, + resolver_entry: entry, + } + .to_account_metas(None), + ) +} + +pub fn read_entry(svm: &LiteSVM, authority: &Pubkey) -> ResolverEntry { + let pda = resolver_entry_pda(authority); + let account = svm.get_account(&pda).expect("resolver entry not found"); + ResolverEntry::try_deserialize(&mut account.data.as_slice()).unwrap() +} diff --git a/programs/descro_ext_resolvers/tests/test_register.rs b/programs/descro_ext_resolvers/tests/test_register.rs new file mode 100644 index 0000000..d221c76 --- /dev/null +++ b/programs/descro_ext_resolvers/tests/test_register.rs @@ -0,0 +1,127 @@ +mod common; +use common::*; +use descro_ext_resolvers::ResolverType; +use solana_keypair::Keypair; +use solana_signer::Signer; + +#[test] +fn registers_entry_with_correct_state() { + let (mut svm, authority) = setup(); + + send( + &mut svm, + ix_register( + &authority.pubkey(), + ResolverType::CentralAuthority, + "Acme Resolvers", + "Fast and fair dispute resolution", + 100, // 1% + authority.pubkey(), + "ipfs://QmTest", + ), + &authority, + ); + + let entry = read_entry(&svm, &authority.pubkey()); + assert_eq!(entry.authority, authority.pubkey()); + assert_eq!(entry.resolver_type, ResolverType::CentralAuthority); + assert_eq!(entry.name, "Acme Resolvers"); + assert_eq!(entry.fee_bps, 100); + assert_eq!(entry.total_resolved, 0); + assert_eq!(entry.ruled_for_buyer, 0); + assert_eq!(entry.ruled_for_seller, 0); +} + +#[test] +fn duplicate_register_fails() { + let (mut svm, authority) = setup(); + + send( + &mut svm, + ix_register( + &authority.pubkey(), + ResolverType::CentralAuthority, + "Acme", + "", + 0, + authority.pubkey(), + "", + ), + &authority, + ); + assert!(!try_send( + &mut svm, + ix_register( + &authority.pubkey(), + ResolverType::CentralAuthority, + "Acme Again", + "", + 0, + authority.pubkey(), + "", + ), + &authority, + )); +} + +#[test] +fn register_empty_name_fails() { + let (mut svm, authority) = setup(); + + assert!(!try_send( + &mut svm, + ix_register( + &authority.pubkey(), + ResolverType::CentralAuthority, + "", + "", + 0, + authority.pubkey(), + "", + ), + &authority, + )); +} + +#[test] +fn register_invalid_fee_bps_fails() { + let (mut svm, authority) = setup(); + + assert!(!try_send( + &mut svm, + ix_register( + &authority.pubkey(), + ResolverType::CentralAuthority, + "Acme", + "", + 10_001, // > 100% + authority.pubkey(), + "", + ), + &authority, + )); +} + +#[test] +fn different_authorities_have_separate_entries() { + let (mut svm, auth1) = setup(); + let auth2 = Keypair::new(); + svm.airdrop(&auth2.pubkey(), 10_000_000_000).unwrap(); + + send( + &mut svm, + ix_register(&auth1.pubkey(), ResolverType::CentralAuthority, "Auth1", "", 0, auth1.pubkey(), ""), + &auth1, + ); + send( + &mut svm, + ix_register(&auth2.pubkey(), ResolverType::JuryDAO, "Auth2", "", 50, auth2.pubkey(), ""), + &auth2, + ); + + let e1 = read_entry(&svm, &auth1.pubkey()); + let e2 = read_entry(&svm, &auth2.pubkey()); + assert_eq!(e1.name, "Auth1"); + assert_eq!(e2.name, "Auth2"); + assert_ne!(resolver_entry_pda(&auth1.pubkey()), resolver_entry_pda(&auth2.pubkey())); +} diff --git a/programs/descro_ext_resolvers/tests/test_update.rs b/programs/descro_ext_resolvers/tests/test_update.rs new file mode 100644 index 0000000..739da4b --- /dev/null +++ b/programs/descro_ext_resolvers/tests/test_update.rs @@ -0,0 +1,83 @@ +mod common; +use common::*; +use descro_ext_resolvers::ResolverType; +use solana_keypair::Keypair; +use solana_signer::Signer; + +fn register_default(svm: &mut litesvm::LiteSVM, authority: &Keypair) { + send( + svm, + ix_register( + &authority.pubkey(), + ResolverType::CentralAuthority, + "Original Name", + "Original description", + 100, + authority.pubkey(), + "ipfs://original", + ), + authority, + ); +} + +#[test] +fn update_changes_metadata() { + let (mut svm, authority) = setup(); + register_default(&mut svm, &authority); + + send( + &mut svm, + ix_update(&authority.pubkey(), "New Name", "New description", 200, "ipfs://new"), + &authority, + ); + + let entry = read_entry(&svm, &authority.pubkey()); + assert_eq!(entry.name, "New Name"); + assert_eq!(entry.description, "New description"); + assert_eq!(entry.fee_bps, 200); + assert_eq!(entry.metadata_uri, "ipfs://new"); +} + +#[test] +fn update_different_authority_cannot_modify_entry() { + let (mut svm, authority) = setup(); + let other = Keypair::new(); + svm.airdrop(&other.pubkey(), 1_000_000_000).unwrap(); + register_default(&mut svm, &authority); + + // `other` signs but claims to BE the authority → seeds derive other's (non-existent) PDA + // → account has no data → Anchor rejects the uninitialized account + assert!(!try_send( + &mut svm, + ix_update(&other.pubkey(), "Hacked", "", 0, ""), + &other, + )); + + // `authority`'s entry is unchanged + let entry = read_entry(&svm, &authority.pubkey()); + assert_eq!(entry.name, "Original Name"); +} + +#[test] +fn update_empty_name_fails() { + let (mut svm, authority) = setup(); + register_default(&mut svm, &authority); + + assert!(!try_send( + &mut svm, + ix_update(&authority.pubkey(), "", "", 0, ""), + &authority, + )); +} + +#[test] +fn update_invalid_fee_bps_fails() { + let (mut svm, authority) = setup(); + register_default(&mut svm, &authority); + + assert!(!try_send( + &mut svm, + ix_update(&authority.pubkey(), "Name", "", 10_001, ""), + &authority, + )); +}