Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions dabdub_contracts/contracts/admin_auth/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "admin-auth"
version = "0.0.0"
edition = "2021"
publish = false

[lib]
crate-type = ["lib", "cdylib"]
doctest = false

[dependencies]
soroban-sdk = { workspace = true }

[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }

[features]
testutils = ["soroban-sdk/testutils"]
132 changes: 132 additions & 0 deletions dabdub_contracts/contracts/admin_auth/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
#![no_std]

mod test;

use soroban_sdk::{contract, contractimpl, contracttype, Address, Env};

/// Admin roles. Admin accounts are stored separately from merchant accounts,
/// so a merchant JWT/address is never accepted on admin-gated operations.
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub enum AdminRole {
Admin,
SuperAdmin,
}

/// Separately-credentialed admin account, distinct from the merchant entity.
#[contracttype]
#[derive(Clone, Debug)]
pub struct AdminUser {
pub admin: Address,
pub role: AdminRole,
pub active: bool,
}

#[contracttype]
#[derive(Clone)]
pub enum DataKey {
SuperAdmin,
Admin(Address),
}

#[contracttype]
struct AdminAddedEvent {
admin: Address,
role: AdminRole,
}

#[contracttype]
struct AdminRevokedEvent {
admin: Address,
}

#[contract]
pub struct AdminAuthContract;

#[contractimpl]
impl AdminAuthContract {
/// Bootstrap the store with a single SuperAdmin. All other admin accounts
/// are created exclusively through `add_admin` (the CLI seed/script path).
pub fn __constructor(env: Env, super_admin: Address) {
env.storage().instance().set(&DataKey::SuperAdmin, &super_admin);
Self::save(
&env,
AdminUser {
admin: super_admin.clone(),
role: AdminRole::SuperAdmin,
active: true,
},
);
}

/// Add an admin to the separate credential store. SuperAdmin only.
pub fn add_admin(env: Env, caller: Address, admin: Address, role: AdminRole) {
caller.require_auth();
Self::require_super_admin(&env, &caller);
Self::save(
&env,
AdminUser {
admin: admin.clone(),
role: role.clone(),
active: true,
},
);
env.events().publish(
("ADMIN_AUTH", "admin_added"),
AdminAddedEvent {
admin,
role,
},
);
}

/// Remove an admin from the credential store. SuperAdmin only.
pub fn revoke_admin(env: Env, caller: Address, admin: Address) {
caller.require_auth();
Self::require_super_admin(&env, &caller);

let key = DataKey::Admin(admin.clone());
if !env.storage().instance().has(&key) {
panic!("admin not found");
}
env.storage().instance().remove(&key);
env.events().publish(
("ADMIN_AUTH", "admin_revoked"),
AdminRevokedEvent { admin },
);
}

pub fn get_admin(env: Env, admin: Address) -> Option<AdminUser> {
env.storage().instance().get(&DataKey::Admin(admin))
}

/// True when the address is an active admin in the store.
pub fn is_admin(env: Env, admin: Address) -> bool {
match env.storage().instance().get::<DataKey, AdminUser>(&DataKey::Admin(admin)) {
Some(user) => user.active,
None => false,
}
}

/// Authorization gate for admin-only operations. Rejects any non-admin
/// address (including merchants), mirroring "require admin JWT on admin routes".
pub fn authorize(env: Env, caller: Address) {
caller.require_auth();
if !Self::is_admin(env.clone(), caller) {
panic!("unauthorized admin");
}
}

fn save(env: &Env, user: AdminUser) {
env.storage()
.instance()
.set(&DataKey::Admin(user.admin.clone()), &user);
}

fn require_super_admin(env: &Env, caller: &Address) {
let super_admin: Address = env.storage().instance().get(&DataKey::SuperAdmin).unwrap();
if caller != &super_admin {
panic!("not super admin");
}
}
}
83 changes: 83 additions & 0 deletions dabdub_contracts/contracts/admin_auth/src/test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#![cfg(test)]

use super::*;
use soroban_sdk::{testutils::Address as _, Env};

fn setup() -> (Env, AdminAuthContractClient<'static>, Address) {
let env = Env::default();
env.mock_all_auths();
let super_admin = Address::generate(&env);
let id = env.register(AdminAuthContract, (&super_admin,));
let client = AdminAuthContractClient::new(&env, &id);
(env, client, super_admin)
}

#[test]
fn test_constructor_bootstraps_super_admin() {
let (env, client, super_admin) = setup();
let user = client.get_admin(&super_admin).unwrap();
assert_eq!(user.role, AdminRole::SuperAdmin);
assert!(user.active);
assert!(client.is_admin(&super_admin));
}

#[test]
fn test_is_admin_false_for_non_admin() {
let (env, client, _super_admin) = setup();
let stranger = Address::generate(&env);
assert!(!client.is_admin(&stranger));
}

#[test]
fn test_add_admin_grants_role() {
let (env, client, super_admin) = setup();
let admin = Address::generate(&env);
client.add_admin(&super_admin, &admin, &AdminRole::Admin);
assert!(client.is_admin(&admin));
let user = client.get_admin(&admin).unwrap();
assert_eq!(user.role, AdminRole::Admin);
}

#[test]
fn test_authorize_allows_admin() {
let (env, client, super_admin) = setup();
let admin = Address::generate(&env);
client.add_admin(&super_admin, &admin, &AdminRole::Admin);
client.authorize(&admin);
}

#[test]
#[should_panic(expected = "unauthorized admin")]
fn test_authorize_rejects_merchant_address() {
let (env, client, _super_admin) = setup();
let merchant = Address::generate(&env);
client.authorize(&merchant);
}

#[test]
#[should_panic(expected = "not super admin")]
fn test_add_admin_requires_super_admin() {
let (env, client, super_admin) = setup();
let admin = Address::generate(&env);
let attacker = Address::generate(&env);
client.add_admin(&super_admin, &admin, &AdminRole::Admin);
client.add_admin(&attacker, &admin, &AdminRole::Admin);
}

#[test]
fn test_revoke_admin() {
let (env, client, super_admin) = setup();
let admin = Address::generate(&env);
client.add_admin(&super_admin, &admin, &AdminRole::Admin);
assert!(client.is_admin(&admin));
client.revoke_admin(&super_admin, &admin);
assert!(!client.is_admin(&admin));
}

#[test]
#[should_panic(expected = "admin not found")]
fn test_revoke_unknown_admin_panics() {
let (env, client, super_admin) = setup();
let stranger = Address::generate(&env);
client.revoke_admin(&super_admin, &stranger);
}
79 changes: 78 additions & 1 deletion dabdub_contracts/contracts/merchant_registry/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
mod test;

use soroban_sdk::{
contract, contractimpl, contracttype, Address, Env, String,
contract, contractimpl, contracttype, Address, Env, String, Vec,
};

/// Lifecycle states for a registered merchant.
Expand Down Expand Up @@ -40,6 +40,8 @@ const MAX_FEE_BPS: u32 = 1000;
pub enum DataKey {
Admin,
Merchant(Address),
/// Index of all registered merchant addresses, for paginated listing.
Merchants,
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -85,6 +87,12 @@ struct MerchantTerminatedEvent {
merchant: Address,
}

#[contracttype]
struct MerchantUpdatedEvent {
merchant: Address,
name: String,
}

// ---------------------------------------------------------------------------
// Contract
// ---------------------------------------------------------------------------
Expand All @@ -100,6 +108,9 @@ impl MerchantRegistryContract {

pub fn __constructor(env: Env, admin: Address) {
env.storage().instance().set(&DataKey::Admin, &admin);
env.storage()
.instance()
.set(&DataKey::Merchants, &Vec::<Address>::new(&env));
}

// ------------------------------------------------------------------
Expand All @@ -125,12 +136,44 @@ impl MerchantRegistryContract {
};
env.storage().persistent().set(&key, &record);

let mut merchants: Vec<Address> = env
.storage()
.instance()
.get(&DataKey::Merchants)
.unwrap();
merchants.push_back(merchant.clone());
env.storage().instance().set(&DataKey::Merchants, &merchants);

env.events().publish(
("REGISTRY", "merchant_registered"),
MerchantRegisteredEvent { merchant: merchant.clone(), name: name.clone() },
);
}

/// Update a registered merchant's business name. Callable by admin only.
pub fn update_merchant(env: Env, caller: Address, merchant: Address, name: String) {
caller.require_auth();
Self::require_admin(&env, &caller);

let key = DataKey::Merchant(merchant.clone());
let mut record: MerchantRecord = env
.storage()
.persistent()
.get(&key)
.expect("Merchant not found");

record.name = name.clone();
env.storage().persistent().set(&key, &record);

env.events().publish(
("REGISTRY", "merchant_updated"),
MerchantUpdatedEvent {
merchant,
name,
},
);
}

/// Suspend a merchant. Callable by admin only.
/// After suspension, the Escrow contract will reject new deposits for
/// this merchant.
Expand Down Expand Up @@ -275,6 +318,28 @@ impl MerchantRegistryContract {
.expect("Merchant not found")
}

/// Paginated list of all registered merchants.
pub fn merchants(env: Env, page: u32, page_size: u32) -> Vec<MerchantRecord> {
if page_size == 0 {
panic!("page size must be > 0");
}
let all: Vec<Address> = env
.storage()
.instance()
.get(&DataKey::Merchants)
.unwrap_or(Vec::new(&env));

let start = (page as u64).saturating_mul(page_size as u64).min(all.len() as u64) as u32;
let end = (start as u64 + page_size as u64).min(all.len() as u64) as u32;

let mut out = Vec::new(&env);
let iter = all.slice(start..end);
for addr in iter.iter() {
out.push_back(Self::get_merchant(env.clone(), addr));
}
out
}

/// Returns `true` when the merchant is registered and Active.
pub fn is_merchant_active(env: Env, merchant: Address) -> bool {
let key = DataKey::Merchant(merchant);
Expand All @@ -285,6 +350,18 @@ impl MerchantRegistryContract {
record.status == MerchantStatus::Active
}

/// Returns `true` when the merchant is registered, Active, and KYC verified.
/// Used by callers (e.g. payment_escrow) to gate deposits on merchant approval.
/// Returns `false` for unregistered merchants.
pub fn is_approved(env: Env, merchant: Address) -> bool {
let key = DataKey::Merchant(merchant);
if !env.storage().persistent().has(&key) {
return false;
}
let record: MerchantRecord = env.storage().persistent().get(&key).unwrap();
record.status == MerchantStatus::Active && record.kyc_verified
}

/// Returns `true` when the merchant is KYC verified.
/// Returns `false` for unregistered merchants.
pub fn is_kyc_verified(env: Env, merchant: Address) -> bool {
Expand Down
Loading