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
2 changes: 1 addition & 1 deletion contracts/allowlist-token/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
130 changes: 103 additions & 27 deletions contracts/allowlist-token/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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]
Expand Down Expand Up @@ -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]
Expand All @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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)]
Expand Down
123 changes: 121 additions & 2 deletions contracts/allowlist-token/src/test.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Loading