Skip to content

Commit c2d5299

Browse files
authored
Merge pull request #292 from ComputerOracle/feature/automated-compliance-checks
feat: implement automated compliance checks
2 parents 104e887 + 5a6e05c commit c2d5299

6 files changed

Lines changed: 188 additions & 7 deletions

File tree

contract/src/errors.rs

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,112 @@ use soroban_sdk::contracterror;
44
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
55
#[repr(u32)]
66
pub enum ContractError {
7+
// Core (1–10)
78
AlreadyInitialized = 1,
89
NotInitialized = 2,
910
InvalidAmount = 3,
1011
InvalidFeeBps = 4,
12+
Overflow = 5,
13+
Unauthorized = 6,
14+
ContractPaused = 7,
15+
InvalidStatus = 8,
16+
TradeNotFound = 9,
17+
ArbitratorNotRegistered = 10,
18+
19+
// Compliance (11–15)
20+
KycNotVerified = 11,
21+
AmlNotCleared = 12,
22+
JurisdictionRestricted = 13,
23+
TradeAmountLimitExceeded = 14,
24+
ComplianceDataMissing = 15,
25+
26+
// Fees / Tiers (16–20)
27+
NoFeesToWithdraw = 16,
28+
InvalidTierConfig = 17,
29+
TierNotFound = 18,
30+
InvalidMetadata = 19,
31+
MetadataValueTooLong = 20,
32+
33+
// Templates (21–26)
34+
TemplateNotFound = 21,
35+
TemplateInactive = 22,
36+
TemplateNameTooLong = 23,
37+
TemplateVersionLimitExceeded = 24,
38+
TemplateAmountMismatch = 25,
39+
InvalidExpiry = 26,
40+
41+
// Arbitrator reputation (27–30)
42+
InvalidRating = 27,
43+
AlreadyRated = 28,
44+
NoArbitrator = 29,
45+
TradeExpired = 30,
46+
TradeNotExpired = 31,
47+
InvalidSplitBps = 32,
48+
49+
// Subscriptions (33–36)
50+
SubscriptionNotFound = 33,
51+
SubscriptionExpired = 34,
52+
SubscriptionAlreadyActive = 35,
53+
54+
// Governance (36–43)
55+
ProposalNotFound = 36,
56+
ProposalNotActive = 37,
57+
AlreadyVoted = 38,
58+
InsufficientVotingPower = 39,
59+
ProposalNotPassed = 40,
60+
ProposalAlreadyExecuted = 41,
61+
VotingEnded = 42,
62+
63+
// Privacy (43–45)
64+
PrivacyDataTooLong = 43,
65+
DisclosureGrantNotFound = 44,
66+
DisclosureUnauthorized = 45,
67+
68+
// Migration / Bridge (46–51)
69+
MigrationAlreadyApplied = 46,
70+
MigrationVersionMismatch = 47,
71+
BridgeOracleNotSet = 48,
72+
BridgeTradeExpired = 49,
73+
BridgeTradeNotExpired = 50,
74+
75+
// Insurance (51–55)
76+
InsuranceProviderNotRegistered = 51,
77+
InsurancePremiumTooHigh = 52,
78+
TradeNotInsured = 53,
79+
InsuranceAlreadyClaimed = 54,
80+
InsuranceClaimNotEligible = 55,
81+
82+
// Oracle (60–64)
83+
OracleNotFound = 60,
84+
OracleAlreadyRegistered = 61,
85+
OracleListFull = 62,
86+
OracleUnavailable = 63,
87+
OraclePriceInvalid = 64,
88+
89+
// AMM (70–74)
90+
AmmPoolNotFound = 70,
91+
AmmSlippageExceeded = 71,
92+
AmmInsufficientShares = 72,
93+
AmmInvalidPair = 73,
94+
AmmPoolAlreadyExists = 74,
95+
96+
// Upgrade (80–85)
97+
UpgradeInProgress = 80,
98+
NoUpgradeProposal = 81,
99+
UpgradeTimelockActive = 82,
100+
NoUpgradeInProgress = 83,
101+
RollbackWindowExpired = 84,
102+
103+
// Multi-sig arbitration (90–94)
104+
InvalidMultiSigConfig = 90,
105+
VotingExpired = 91,
106+
VotingNotExpired = 92,
107+
NoConsensus = 93,
108+
109+
// Social (95–96)
110+
CannotFollowSelf = 95,
111+
NotFollowing = 96,
112+
}
11113
KycNotVerified = 5,
12114
AmlNotCleared = 6,
13115
JurisdictionRestricted = 7,
@@ -123,4 +225,4 @@ pub enum ContractError {
123225
// Social feature errors (70-74)
124226
CannotFollowSelf = 70,
125227
NotFollowing = 71,
126-
}
228+
}

contract/src/events.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,38 @@ pub fn emit_trade_created(env: &Env, trade_id: u64, seller: Address, buyer: Addr
165165
env.events().publish((cat_trade(), symbol_short!("created")), EvTradeCreated { v: EVENT_VERSION, trade_id, seller, buyer, amount, currency });
166166
}
167167

168+
// ---------------------------------------------------------------------------
169+
// Compliance events
170+
// ---------------------------------------------------------------------------
171+
172+
fn cat_compliance() -> Symbol { symbol_short!("compl") }
173+
174+
#[contracttype] #[derive(Clone, Debug)]
175+
pub struct EvComplianceFailed { pub v: u32, pub user: Address, pub reason: String }
176+
#[contracttype] #[derive(Clone, Debug)]
177+
pub struct EvCompliancePassed { pub v: u32, pub trade_id: u64, pub seller: Address, pub buyer: Address, pub amount: u64 }
178+
#[contracttype] #[derive(Clone, Debug)]
179+
pub struct EvComplianceUpdated { pub v: u32, pub user: Address }
180+
181+
pub fn emit_compliance_failed(env: &Env, user: Address, reason: &String) {
182+
env.events().publish(
183+
(cat_compliance(), symbol_short!("failed")),
184+
EvComplianceFailed { v: EVENT_VERSION, user, reason: reason.clone() },
185+
);
186+
}
187+
188+
pub fn emit_compliance_passed(env: &Env, trade_id: u64, seller: Address, buyer: Address, amount: u64) {
189+
env.events().publish(
190+
(cat_compliance(), symbol_short!("passed")),
191+
EvCompliancePassed { v: EVENT_VERSION, trade_id, seller, buyer, amount },
192+
);
193+
}
194+
195+
pub fn emit_compliance_updated(env: &Env, user: Address) {
196+
env.events().publish(
197+
(cat_compliance(), symbol_short!("updated")),
198+
EvComplianceUpdated { v: EVENT_VERSION, user },
199+
);
168200
pub fn emit_compliance_failed(env: &Env, user: Address, reason: &soroban_sdk::String) {
169201
env.events().publish((cat_sys(), symbol_short!("compl_fail")), EvComplianceFailed { v: EVENT_VERSION, user, reason: reason.clone() });
170202
}

contract/src/lib.rs

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -141,27 +141,49 @@ fn require_admin(env: &Env, admin: &Address) -> Result<(), ContractError> {
141141
}
142142

143143
fn validate_user_compliance(env: &Env, user: &Address, amount: u64) -> Result<(), ContractError> {
144-
let comp = storage::get_user_compliance(env, user).ok_or(ContractError::KycNotVerified)?;
144+
let comp = storage::get_user_compliance(env, user)
145+
.ok_or(ContractError::ComplianceDataMissing)?;
146+
145147
if comp.kyc_status != crate::types::KycStatus::Verified {
146-
events::emit_compliance_failed(env, user.clone(), &soroban_sdk::String::from_str(env, "KYC_NOT_VERIFIED"));
148+
events::emit_compliance_failed(
149+
env,
150+
user.clone(),
151+
&soroban_sdk::String::from_str(env, "KYC_NOT_VERIFIED"),
152+
);
147153
return Err(ContractError::KycNotVerified);
148154
}
149155
if !comp.aml_cleared {
150-
events::emit_compliance_failed(env, user.clone(), &soroban_sdk::String::from_str(env, "AML_NOT_CLEARED"));
156+
events::emit_compliance_failed(
157+
env,
158+
user.clone(),
159+
&soroban_sdk::String::from_str(env, "AML_NOT_CLEARED"),
160+
);
151161
return Err(ContractError::AmlNotCleared);
152162
}
153163
if !storage::is_jurisdiction_allowed(env, &comp.jurisdiction) {
154-
events::emit_compliance_failed(env, user.clone(), &soroban_sdk::String::from_str(env, "JURISDICTION_BLOCKED"));
164+
events::emit_compliance_failed(
165+
env,
166+
user.clone(),
167+
&soroban_sdk::String::from_str(env, "JURISDICTION_BLOCKED"),
168+
);
155169
return Err(ContractError::JurisdictionRestricted);
156170
}
157171
let user_limit = storage::get_user_trade_limit(env, user);
158172
if user_limit > 0 && amount > user_limit {
159-
events::emit_compliance_failed(env, user.clone(), &soroban_sdk::String::from_str(env, "USER_LIMIT_EXCEEDED"));
173+
events::emit_compliance_failed(
174+
env,
175+
user.clone(),
176+
&soroban_sdk::String::from_str(env, "USER_LIMIT_EXCEEDED"),
177+
);
160178
return Err(ContractError::TradeAmountLimitExceeded);
161179
}
162180
let global_limit = storage::get_global_trade_limit(env);
163181
if amount > global_limit {
164-
events::emit_compliance_failed(env, user.clone(), &soroban_sdk::String::from_str(env, "GLOBAL_LIMIT_EXCEEDED"));
182+
events::emit_compliance_failed(
183+
env,
184+
user.clone(),
185+
&soroban_sdk::String::from_str(env, "GLOBAL_LIMIT_EXCEEDED"),
186+
);
165187
return Err(ContractError::TradeAmountLimitExceeded);
166188
}
167189
Ok(())
@@ -389,6 +411,7 @@ impl StellarEscrowContract {
389411
require_not_paused(&env)?;
390412
require_admin(&env, &admin)?;
391413
storage::save_user_compliance(&env, &user, &compliance);
414+
events::emit_compliance_updated(&env, user);
392415
Ok(())
393416
}
394417

contract/src/test.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ fn setup() -> (Env, Address, Address, Address, Address, Address, StellarEscrowCo
2525
let client = StellarEscrowContractClient::new(&env, &contract_id);
2626
client.initialize(&admin, &token_addr, &100u32); // 1% fee
2727

28+
// Default compliance: all participants verified, US jurisdiction
29+
let compliant = crate::types::UserCompliance {
30+
kyc_status: crate::types::KycStatus::Verified,
31+
aml_cleared: true,
32+
jurisdiction: soroban_sdk::String::from_str(&env, "US"),
33+
};
34+
client.set_user_compliance(&admin, &seller, &compliant);
35+
client.set_user_compliance(&admin, &buyer, &compliant);
36+
client.set_user_compliance(&admin, &arbitrator, &compliant);
37+
2838
(env, token_addr, admin, seller, buyer, arbitrator, client)
2939
}
3040

indexer/src/compliance_service/mod.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,23 @@ impl ComplianceService {
111111
let trade_id = data.get("trade_id")?.as_u64();
112112
let seller = data.get("seller")?.as_str()?.to_string();
113113
let buyer = data.get("buyer")?.as_str()?.to_string();
114+
let amount = data.get("amount").and_then(|v| v.as_u64()).unwrap_or(0);
114115

115116
let (seller_check, buyer_check) = tokio::join!(
116117
self.check_address(&seller, trade_id),
117118
self.check_address(&buyer, trade_id),
118119
);
119120

121+
// Enforce trade amount limits
122+
if amount > self.config.max_trade_amount && self.config.max_trade_amount > 0 {
123+
tracing::warn!(
124+
trade_id = ?trade_id,
125+
amount,
126+
limit = self.config.max_trade_amount,
127+
"Trade amount exceeds configured limit"
128+
);
129+
}
130+
120131
// Emit compliance event if either party is blocked
121132
if seller_check.status == ComplianceStatus::Blocked
122133
|| buyer_check.status == ComplianceStatus::Blocked

indexer/src/config.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ pub struct ComplianceConfig {
5454
pub blocked_jurisdictions: Vec<String>,
5555
#[serde(default)]
5656
pub reporting_webhook_url: String,
57+
/// Maximum allowed trade amount in stroops (0 = unlimited)
58+
#[serde(default)]
59+
pub max_trade_amount: u64,
5760
}
5861

5962
fn default_kyc_level() -> u8 { 1 }

0 commit comments

Comments
 (0)