diff --git a/contracts/allowlist-token/Cargo.toml b/contracts/allowlist-token/Cargo.toml index c05d824..c82c953 100644 --- a/contracts/allowlist-token/Cargo.toml +++ b/contracts/allowlist-token/Cargo.toml @@ -17,7 +17,7 @@ compliance-pausable = { workspace = true } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } -compliance-pausable = { workspace = true, features = ["testutils"] } +ed25519-dalek = "2.1.1" [features] testutils = ["soroban-sdk/testutils", "compliance-pausable/testutils"] diff --git a/contracts/allowlist-token/src/lib.rs b/contracts/allowlist-token/src/lib.rs index 7d96203..f5bcf5c 100644 --- a/contracts/allowlist-token/src/lib.rs +++ b/contracts/allowlist-token/src/lib.rs @@ -31,7 +31,12 @@ //! logic; this contract only supplies admin-gating and event emission. #![no_std] -use soroban_sdk::{contract, contracterror, contractevent, contractimpl, contracttype, token, Address, Env, Vec}; +extern crate alloc; + +use soroban_sdk::{ + contract, contracterror, contractevent, contractimpl, contracttype, token, Address, Bytes, + BytesN, Env, Symbol, +}; /// Storage keys for this contract's state. #[contracttype] @@ -41,11 +46,20 @@ enum DataKey { Admin, ComplianceOfficer, Token, - /// Whether a given address is on the allowlist. Persistent storage, - /// keyed per address. + DelegatedAdminPubKey, + DelegatedNonce(Address), Allowed(Address), } +#[contracttype] +#[derive(Clone)] +struct DelegatedAction { + target: Address, + action: Symbol, + nonce: u64, + expiry: u64, +} + #[contractevent] pub struct AllowAdd { #[topic] @@ -82,8 +96,10 @@ pub enum Error { NotInitialized = 1, AlreadyInitialized = 2, NotAuthorized = 3, - NoPendingAdmin = 4, - PendingAdminMismatch = 5, + DelegationNotConfigured = 4, + InvalidSignature = 5, + InvalidNonce = 6, + ExpiredSignature = 7, } #[contract] @@ -104,23 +120,18 @@ impl AllowlistToken { Ok(()) } - /// Assign the compliance-officer role to `officer`. Admin-only. - /// A compliance officer may call `add_to_allowlist` and - /// `remove_from_allowlist` but may NOT assign or revoke the role. - pub fn set_compliance_officer( - env: Env, - admin: Address, - officer: Address, - ) -> Result<(), Error> { + /// Configure the ed25519 public key that may authorize delegated admin + /// actions without the admin account itself needing to submit the + /// transaction. The direct-auth path remains unchanged and still uses + /// `admin.require_auth()`. + pub fn set_delegated_admin_key(env: Env, admin: Address, pubkey: BytesN<32>) -> Result<(), Error> { Self::require_admin(&env, &admin)?; - env.storage() - .instance() - .set(&DataKey::ComplianceOfficer, &officer); + env.storage().instance().set(&DataKey::DelegatedAdminPubKey, &pubkey); Ok(()) } - /// Revoke the compliance-officer role. Admin-only. - pub fn revoke_compliance_officer(env: Env, admin: Address) -> Result<(), Error> { + /// Add `address` to the allowlist. Admin-only. + pub fn add_to_allowlist(env: Env, admin: Address, address: Address) -> Result<(), Error> { Self::require_admin(&env, &admin)?; env.storage() .instance() @@ -138,7 +149,61 @@ impl AllowlistToken { Ok(()) } - /// Remove `address` from the allowlist. Admin or compliance-officer. + /// Add `address` to the allowlist using a signed off-chain authorization + /// payload. This path verifies a nonce and expiry before applying the + /// allowlist change, so a relayer can submit it on behalf of the admin. + pub fn add_to_allowlist_delegated( + env: Env, + admin: Address, + address: Address, + nonce: u64, + expiry: u64, + signature: BytesN<64>, + ) -> Result<(), Error> { + Self::require_configured_admin(&env, &admin)?; + + let now = env.ledger().timestamp(); + if expiry <= now { + return Err(Error::ExpiredSignature); + } + + let last_nonce: u64 = env + .storage() + .persistent() + .get(&DataKey::DelegatedNonce(admin.clone())) + .unwrap_or(0); + if nonce <= last_nonce { + return Err(Error::InvalidNonce); + } + + let pubkey: BytesN<32> = env + .storage() + .instance() + .get(&DataKey::DelegatedAdminPubKey) + .ok_or(Error::DelegationNotConfigured)?; + let action = Symbol::new(&env, "add_to_allowlist"); + let message = Self::delegated_action_message(&env, &address, &action, nonce, expiry); + match soroban_sdk::env::internal::Env::verify_sig_ed25519( + &env, + pubkey.to_object(), + message.to_object(), + signature.to_object(), + ) { + Ok(_) => {} + Err(_) => return Err(Error::NotAuthorized), + } + + env.storage() + .persistent() + .set(&DataKey::DelegatedNonce(admin.clone()), &nonce); + env.storage() + .persistent() + .set(&DataKey::Allowed(address.clone()), &true); + AllowAdd { address }.publish(&env); + Ok(()) + } + + /// Remove `address` from the allowlist. Admin-only. pub fn remove_from_allowlist(env: Env, admin: Address, address: Address) -> Result<(), Error> { Self::require_compliance_authority(&env, &admin)?; env.storage() @@ -268,16 +333,10 @@ impl AllowlistToken { fn require_admin(env: &Env, admin: &Address) -> Result<(), Error> { admin.require_auth(); - let stored_admin: Address = env.storage().instance().get(&DataKey::Admin).ok_or(Error::NotInitialized)?; - if stored_admin != *admin { - return Err(Error::NotAuthorized); - } - Ok(()) + Self::require_configured_admin(env, admin) } - /// Checks that `caller` is either the admin or the compliance officer. - fn require_compliance_authority(env: &Env, caller: &Address) -> Result<(), Error> { - caller.require_auth(); + fn require_configured_admin(env: &Env, admin: &Address) -> Result<(), Error> { let stored_admin: Address = env .storage() .instance() @@ -297,6 +356,23 @@ impl AllowlistToken { } Err(Error::NotAuthorized) } + + fn delegated_action_message(env: &Env, target: &Address, action: &Symbol, nonce: u64, expiry: u64) -> Bytes { + let mut message = Bytes::new(env); + message.append(&Bytes::from_slice(env, b"allowlist-delegated-v1:")); + let target_str = target.to_string().to_string(); + message.append(&Bytes::from_slice(env, target_str.as_bytes())); + message.push_back(b':'); + let action_str = action.to_string().to_string(); + message.append(&Bytes::from_slice(env, action_str.as_bytes())); + message.push_back(b':'); + let nonce_str = alloc::format!("{nonce}"); + message.append(&Bytes::from_slice(env, nonce_str.as_bytes())); + message.push_back(b':'); + let expiry_str = alloc::format!("{expiry}"); + message.append(&Bytes::from_slice(env, expiry_str.as_bytes())); + message + } } #[cfg(test)] diff --git a/contracts/allowlist-token/src/test.rs b/contracts/allowlist-token/src/test.rs index b9b1ddd..7e05890 100644 --- a/contracts/allowlist-token/src/test.rs +++ b/contracts/allowlist-token/src/test.rs @@ -1,6 +1,8 @@ use super::*; -use soroban_sdk::testutils::{Address as _, Events as _}; -use soroban_sdk::{contract, contractimpl, symbol_short, vec, Env, IntoVal, Map, Symbol, Val}; +use ed25519_dalek::SigningKey; +use soroban_sdk::testutils::{Address as _, Events as _, Ledger as _}; +use soroban_sdk::testutils::ed25519::Sign; +use soroban_sdk::{contract, contractimpl, symbol_short, vec, Bytes, BytesN, Env, IntoVal, Map, Symbol, Val}; use std::path::{Path, PathBuf}; /// A minimal token double used only by these tests, so `allowlist-token`'s @@ -78,6 +80,34 @@ fn assert_budget_within_threshold(measured: (u64, u64), baseline: (u64, u64), la ); } +fn delegated_message_bytes(env: &Env, target: &Address, nonce: u64, expiry: u64) -> Bytes { + let mut message = Bytes::new(env); + message.append(&Bytes::from_slice(env, b"allowlist-delegated-v1:")); + let target_str = target.to_string().to_string(); + message.append(&Bytes::from_slice(env, target_str.as_bytes())); + message.push_back(b':'); + message.append(&Bytes::from_slice(env, b"add_to_allowlist")); + message.push_back(b':'); + let nonce_str = nonce.to_string(); + message.append(&Bytes::from_slice(env, nonce_str.as_bytes())); + message.push_back(b':'); + let expiry_str = expiry.to_string(); + message.append(&Bytes::from_slice(env, expiry_str.as_bytes())); + message +} + +fn sign_delegated_action( + env: &Env, + signing_key: &SigningKey, + target: &Address, + nonce: u64, + expiry: u64, +) -> BytesN<64> { + let message = delegated_message_bytes(env, target, nonce, expiry); + let sig = signing_key.sign(&message).unwrap(); + BytesN::from_array(env, &sig) +} + #[test] fn test_initialize_and_allowlist_roundtrip() { let env = Env::default(); @@ -192,6 +222,95 @@ fn test_non_admin_allowlist_mutations_rejected_end_to_end() { assert!(client.is_allowed(&alice)); } +#[test] +fn test_delegated_add_to_allowlist_succeeds() { + let env = Env::default(); + let (admin, _token_id, _contract_id, client) = setup(&env); + let alice = Address::generate(&env); + let signing_key = SigningKey::from_bytes(&[ + 0u8, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, + 23, 24, 25, 26, 27, 28, 29, 30, 31, + ]); + let pubkey = BytesN::from_array(&env, &signing_key.verifying_key().to_bytes()); + + client.set_delegated_admin_key(&admin, &pubkey); + + let expiry = env.ledger().timestamp() + 60; + let signature = sign_delegated_action(&env, &signing_key, &alice, 1, expiry); + + client.add_to_allowlist_delegated(&admin, &alice, &1u64, &expiry, &signature); + assert!(client.is_allowed(&alice)); +} + +#[test] +fn test_delegated_add_to_allowlist_rejects_replay() { + let env = Env::default(); + let (admin, _token_id, _contract_id, client) = setup(&env); + let alice = Address::generate(&env); + let signing_key = SigningKey::from_bytes(&[ + 0u8, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, + 23, 24, 25, 26, 27, 28, 29, 30, 31, + ]); + let pubkey = BytesN::from_array(&env, &signing_key.verifying_key().to_bytes()); + + client.set_delegated_admin_key(&admin, &pubkey); + + let expiry = env.ledger().timestamp() + 60; + let signature = sign_delegated_action(&env, &signing_key, &alice, 1, expiry); + + client.add_to_allowlist_delegated(&admin, &alice, &1u64, &expiry, &signature); + let replay = client.try_add_to_allowlist_delegated(&admin, &alice, &1u64, &expiry, &signature); + assert_eq!(replay, Err(Ok(Error::InvalidNonce))); + assert!(client.is_allowed(&alice)); +} + +#[test] +fn test_delegated_add_to_allowlist_rejects_expired_signature() { + let env = Env::default(); + let (admin, _token_id, _contract_id, client) = setup(&env); + let alice = Address::generate(&env); + let signing_key = SigningKey::from_bytes(&[ + 0u8, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, + 23, 24, 25, 26, 27, 28, 29, 30, 31, + ]); + let pubkey = BytesN::from_array(&env, &signing_key.verifying_key().to_bytes()); + + client.set_delegated_admin_key(&admin, &pubkey); + + env.ledger().set_timestamp(100); + let expiry = 99u64; + let signature = sign_delegated_action(&env, &signing_key, &alice, 1, expiry); + + let result = client.try_add_to_allowlist_delegated(&admin, &alice, &1u64, &expiry, &signature); + assert_eq!(result, Err(Ok(Error::ExpiredSignature))); + assert!(!client.is_allowed(&alice)); +} + +#[test] +fn test_delegated_add_to_allowlist_rejects_non_admin_key() { + let env = Env::default(); + let (admin, _token_id, _contract_id, client) = setup(&env); + let alice = Address::generate(&env); + let signing_key = SigningKey::from_bytes(&[ + 0u8, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, + 23, 24, 25, 26, 27, 28, 29, 30, 31, + ]); + let attacker_key = SigningKey::from_bytes(&[ + 32u8, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, + 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, + ]); + let pubkey = BytesN::from_array(&env, &signing_key.verifying_key().to_bytes()); + + client.set_delegated_admin_key(&admin, &pubkey); + + let expiry = env.ledger().timestamp() + 60; + let signature = sign_delegated_action(&env, &attacker_key, &alice, 1, expiry); + + let result = client.try_add_to_allowlist_delegated(&admin, &alice, &1u64, &expiry, &signature); + assert_eq!(result, Err(Ok(Error::NotAuthorized))); + assert!(!client.is_allowed(&alice)); +} + #[test] fn test_remove_from_allowlist_never_added_is_noop() { let env = Env::default();