descro resolvers extension

This commit is contained in:
thesn10
2026-05-18 14:29:29 +02:00
parent 98ef8ec9c3
commit 8a054cbf27
17 changed files with 869 additions and 13 deletions

View File

@@ -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"))'] }

View File

@@ -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,
}

View File

@@ -0,0 +1,7 @@
pub mod register;
pub mod update;
pub mod update_stats;
pub use register::*;
pub use update::*;
pub use update_stats::*;

View File

@@ -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<RegisterResolver>,
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(())
}

View File

@@ -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<UpdateResolver>,
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(())
}

View File

@@ -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<UpdateStats>, 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(())
}

View File

@@ -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<RegisterResolver>,
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<UpdateResolver>,
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<UpdateStats>, ruling: Ruling) -> Result<()> {
update_stats::handler(ctx, ruling)
}
}

View File

@@ -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,
}

View File

@@ -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()
}

View File

@@ -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()));
}

View File

@@ -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,
));
}