diff --git a/backend/src/__tests__/rateLimiter.test.ts b/backend/src/__tests__/rateLimiter.test.ts index f662c288..91810712 100644 --- a/backend/src/__tests__/rateLimiter.test.ts +++ b/backend/src/__tests__/rateLimiter.test.ts @@ -45,11 +45,32 @@ describe('Rate Limiting Integration', () => { it('should include rate limit headers for API routes', async () => { const apiResponse = await request(app).get('/api/v1/rate-limit/tiers'); + expect(apiResponse.status).toBe(200); expect(apiResponse.headers).toHaveProperty('x-ratelimit-limit'); expect(apiResponse.headers).toHaveProperty('x-ratelimit-remaining'); expect(apiResponse.headers).toHaveProperty('x-ratelimit-reset'); }); + it('should return available tiers from the public rate limit endpoint', async () => { + const response = await request(app).get('/api/v1/rate-limit/tiers'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.tiers).toHaveProperty('auth'); + expect(response.body.data.tiers).toHaveProperty('api'); + expect(response.body.data.tiers).toHaveProperty('data'); + expect(response.body.data.tiers).toHaveProperty('strict'); + }); + + it('should reject invalid rate limit status tiers with a 400', async () => { + const response = await request(app).get( + '/api/v1/rate-limit/status?identifier=test-client&tier=invalid' + ); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Invalid tier'); + }); + it('should return 404/200 but still have headers even if unauthenticated', async () => { const response = await request(app).get('/api/non-existent-route'); // It should hit the middleware before failing with 404 or 401 diff --git a/backend/src/app.ts b/backend/src/app.ts index a81beaad..73c405cd 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -142,8 +142,9 @@ fs.writeFileSync(path.join(__appDirname, '../openapi.json'), JSON.stringify(swag // ─── API Versioning ─────────────────────────────────────────────────────────── app.use(apiVersionMiddleware); -// Versioned API — canonical entry point -app.use('/api/v1', apiRateLimit(), v1Routes); +// Versioned API — canonical entry point. Individual v1 route groups apply the +// appropriate rate-limit tier so requests are not double-counted. +app.use('/api/v1', v1Routes); // API root — discovery endpoint app.get('/api', (_req, res) => { diff --git a/backend/src/controllers/rateLimitController.ts b/backend/src/controllers/rateLimitController.ts index 325790f4..e0372209 100644 --- a/backend/src/controllers/rateLimitController.ts +++ b/backend/src/controllers/rateLimitController.ts @@ -13,6 +13,14 @@ export class RateLimitController { } const tierName = (tier as RateLimitTierName) || 'api'; + if (!['auth', 'api', 'data', 'strict'].includes(tierName)) { + res.status(400).json({ + error: 'Invalid tier', + message: 'tier must be one of: auth, api, data, strict', + }); + return; + } + const tierConfig = rateLimitService.getTierConfig(tierName); const result = await rateLimitService.checkRateLimit(identifier as string, tierName); diff --git a/backend/src/routes/rateLimitRoutes.ts b/backend/src/routes/rateLimitRoutes.ts index d91452df..dc17a1e7 100644 --- a/backend/src/routes/rateLimitRoutes.ts +++ b/backend/src/routes/rateLimitRoutes.ts @@ -32,5 +32,7 @@ const router = Router(); * 200: * description: Success */ +router.get('/status', RateLimitController.getStatus); +router.get('/tiers', RateLimitController.getTiers); export default router; diff --git a/contracts/orgusd/src/lib.rs b/contracts/orgusd/src/lib.rs index 251f645f..9ef905ff 100644 --- a/contracts/orgusd/src/lib.rs +++ b/contracts/orgusd/src/lib.rs @@ -27,7 +27,7 @@ #![allow(clippy::too_many_arguments)] use soroban_sdk::{ - contract, contracterror, contractevent, contractimpl, contracttype, Address, Env, + contract, contracterror, contractevent, contractimpl, contracttype, Address, Env, String, }; // ── Errors ──────────────────────────────────────────────────────────────────── @@ -56,6 +56,18 @@ pub enum OrgUsdError { SelfTransfer = 9, } +/// SEP-0001 asset metadata mirrored from `.well-known/stellar.toml`. +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Sep1AssetMetadata { + pub code: String, + pub issuer: String, + pub home_domain: String, + pub display_decimals: u32, + pub anchored: bool, + pub anchor_asset: String, +} + // ── Storage keys ───────────────────────────────────────────────────────────── #[contracttype] @@ -175,6 +187,43 @@ impl OrgUsdContract { soroban_sdk::String::from_str(&env, env!("CARGO_PKG_AUTHORS")) } + /// Returns SEP-0001 metadata expected for the ORGUSD asset. + /// + /// These values must stay synchronized with `backend/.well-known/stellar.toml` + /// so clients can verify the on-chain asset contract against hosted + /// Stellar asset metadata. + pub fn sep1_metadata(env: Env) -> Sep1AssetMetadata { + Sep1AssetMetadata { + code: String::from_str(&env, "ORGUSD"), + issuer: String::from_str( + &env, + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + ), + home_domain: String::from_str(&env, "payd.example.com"), + display_decimals: 2, + anchored: true, + anchor_asset: String::from_str(&env, "USD"), + } + } + + /// Verifies externally supplied SEP-0001 metadata against the contract's + /// expected ORGUSD asset metadata. + pub fn verify_sep1_metadata( + env: Env, + code: String, + issuer: String, + home_domain: String, + display_decimals: u32, + anchor_asset: String, + ) -> bool { + let expected = Self::sep1_metadata(env); + expected.code == code + && expected.issuer == issuer + && expected.home_domain == home_domain + && expected.display_decimals == display_decimals + && expected.anchor_asset == anchor_asset + } + // ── Queries ─────────────────────────────────────────────────────────────── /// Returns the total minted supply of ORGUSD. @@ -554,6 +603,49 @@ mod tests { assert_eq!(client.name(), soroban_sdk::String::from_str(&env, "ORGUSD")); } + #[test] + fn test_sep1_metadata_matches_stellar_toml_values() { + let (env, _, client) = setup(); + let metadata = client.sep1_metadata(); + + assert_eq!(metadata.code, String::from_str(&env, "ORGUSD")); + assert_eq!( + metadata.issuer, + String::from_str( + &env, + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" + ) + ); + assert_eq!(metadata.home_domain, String::from_str(&env, "payd.example.com")); + assert_eq!(metadata.display_decimals, 2); + assert!(metadata.anchored); + assert_eq!(metadata.anchor_asset, String::from_str(&env, "USD")); + } + + #[test] + fn test_verify_sep1_metadata() { + let (env, _, client) = setup(); + + assert!(client.verify_sep1_metadata( + &String::from_str(&env, "ORGUSD"), + &String::from_str( + &env, + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + ), + &String::from_str(&env, "payd.example.com"), + &2, + &String::from_str(&env, "USD"), + )); + + assert!(!client.verify_sep1_metadata( + &String::from_str(&env, "ORGUSD"), + &String::from_str(&env, "GDIFFERENTISSUER0000000000000000000000000000000000000000"), + &String::from_str(&env, "payd.example.com"), + &2, + &String::from_str(&env, "USD"), + )); + } + // ── authorize / revoke ──────────────────────────────────────────────────── #[test] diff --git a/contracts/revenue_split/README.md b/contracts/revenue_split/README.md new file mode 100644 index 00000000..8c28808d --- /dev/null +++ b/contracts/revenue_split/README.md @@ -0,0 +1,34 @@ +# Revenue Split Contract + +The revenue split contract distributes incoming token payments to configured +recipients by basis points. The final recipient receives any rounding remainder +so the full input amount is distributed. + +## Public Functions + +| Function | Purpose | +| --- | --- | +| `init(admin, shares)` | Initializes the contract with an admin and recipient split. | +| `distribute(token, from, amount)` | Transfers `amount` of `token` from `from` to recipients according to the configured split. | +| `preview_distribution(amount)` | Returns the recipient amounts that would be paid for `amount`. | +| `update_recipients(new_shares)` | Replaces recipient split configuration. Admin only. | +| `set_admin(new_admin)` | Transfers admin control. Admin only. | +| `set_paused(paused)` | Pauses or unpauses distributions. Admin only. | +| `add_supported_asset(token)` | Adds a token contract address to the supported-asset allowlist. Admin only. | +| `remove_supported_asset(token)` | Removes a token contract address from the supported-asset allowlist. Admin only. | +| `get_supported_assets()` | Returns the configured supported token assets. | +| `is_asset_supported(token)` | Returns whether a token can currently be distributed. | +| `get_total_distributed(token)` | Returns cumulative distributed amount for a token. | +| `get_distribution_count()` | Returns the number of successful distributions. | +| `bump_ttl()` | Extends TTL for critical storage entries. Admin only. | + +## Multi-Asset Support + +The contract tracks distribution totals per token address and can distribute any +Soroban token contract. For backward compatibility, an empty supported-asset +allowlist means all token addresses are accepted. Once an admin adds one or more +assets with `add_supported_asset`, `distribute` rejects tokens that are not in +the allowlist with `UnsupportedAsset`. + +Replay protection is ledger-wide, so only one distribution can execute per +ledger sequence even when different assets are used. diff --git a/contracts/revenue_split/src/lib.rs b/contracts/revenue_split/src/lib.rs index 5689d9a2..d88d49c1 100644 --- a/contracts/revenue_split/src/lib.rs +++ b/contracts/revenue_split/src/lib.rs @@ -22,6 +22,7 @@ pub enum RevenueSplitError { LedgerReplayDetected = 6, UnauthorizedDistribution = 7, ContractPaused = 8, + UnsupportedAsset = 9, } // ── Events ──────────────────────────────────────────────────────────────────── @@ -56,6 +57,20 @@ pub struct PauseStateChangedEvent { pub admin: Address, } +/// Emitted when an admin adds support for a token asset. +#[contractevent] +pub struct AssetSupportedEvent { + pub admin: Address, + pub token: Address, +} + +/// Emitted when an admin removes support for a token asset. +#[contractevent] +pub struct AssetRemovedEvent { + pub admin: Address, + pub token: Address, +} + // ── Storage ─────────────────────────────────────────────────────────────────── #[contracttype] @@ -70,6 +85,8 @@ pub enum DataKey { Paused, /// Cumulative count of completed distributions. DistributionCount, + /// Optional admin-managed allowlist of token assets. + SupportedAssets, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -172,7 +189,7 @@ impl RevenueSplitContract { ) -> Result<(), RevenueSplitError> { let admin = Self::load_admin(&env); admin.require_auth(); - Self::validate_shares(&new_shares); + Self::validate_shares(&new_shares)?; let recipient_count = new_shares.len(); Self::store_recipients(&env, &new_shares); Self::bump_core_ttl(&env); @@ -216,6 +233,55 @@ impl RevenueSplitContract { .unwrap_or(0) } + /// Adds a token asset to the supported-asset allowlist (admin only). + /// + /// An empty allowlist preserves legacy behavior and accepts any token. + /// Once at least one asset is added, `distribute` only accepts listed + /// assets. + pub fn add_supported_asset(env: Env, token: Address) -> Result<(), RevenueSplitError> { + let admin = Self::load_admin(&env); + admin.require_auth(); + + let mut assets = Self::load_supported_assets_or_empty(&env); + if !Self::asset_vec_contains(&assets, &token) { + assets.push_back(token.clone()); + Self::store_supported_assets(&env, &assets); + } + + AssetSupportedEvent { admin, token }.publish(&env); + Ok(()) + } + + /// Removes a token asset from the supported-asset allowlist (admin only). + pub fn remove_supported_asset(env: Env, token: Address) -> Result<(), RevenueSplitError> { + let admin = Self::load_admin(&env); + admin.require_auth(); + + let assets = Self::load_supported_assets_or_empty(&env); + let mut updated = Vec::new(&env); + for asset in assets.iter() { + if asset != token { + updated.push_back(asset); + } + } + Self::store_supported_assets(&env, &updated); + + AssetRemovedEvent { admin, token }.publish(&env); + Ok(()) + } + + /// Returns the configured supported token assets. + /// + /// An empty list means the legacy open policy is active. + pub fn get_supported_assets(env: Env) -> Vec
{ + Self::load_supported_assets_or_empty(&env) + } + + /// Returns whether `token` is currently distributable. + pub fn is_asset_supported(env: Env, token: Address) -> bool { + Self::is_asset_supported_internal(&env, &token) + } + /// Distributes a specific token amount from a sender to the listed recipients based on their shares. /// /// ### Algorithm: Basis Points Distribution @@ -240,6 +306,9 @@ impl RevenueSplitContract { Self::require_not_paused(&env)?; from.require_auth(); + if !Self::is_asset_supported_internal(&env, &token) { + return Err(RevenueSplitError::UnsupportedAsset); + } Self::require_unique_ledger(&env)?; let shares = Self::load_recipients(&env); @@ -418,6 +487,47 @@ impl RevenueSplitContract { ); } + fn load_supported_assets_or_empty(env: &Env) -> Vec
{ + let key = DataKey::SupportedAssets; + let assets = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| Vec::new(env)); + if env.storage().persistent().has(&key) { + env.storage().persistent().extend_ttl( + &key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + } + assets + } + + fn store_supported_assets(env: &Env, assets: &Vec
) { + let key = DataKey::SupportedAssets; + env.storage().persistent().set(&key, assets); + env.storage().persistent().extend_ttl( + &key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + } + + fn asset_vec_contains(assets: &Vec
, token: &Address) -> bool { + for asset in assets.iter() { + if asset == *token { + return true; + } + } + false + } + + fn is_asset_supported_internal(env: &Env, token: &Address) -> bool { + let assets = Self::load_supported_assets_or_empty(env); + assets.is_empty() || Self::asset_vec_contains(&assets, token) + } + /// Internal helper to calculate the distribution of an amount across recipients. /// /// The final recipient absorbs any rounding remainder to ensure 100% of @@ -460,6 +570,7 @@ impl RevenueSplitContract { DataKey::Admin, DataKey::Recipients, DataKey::DistributionCount, + DataKey::SupportedAssets, ] { if env.storage().persistent().has(&key) { env.storage().persistent().extend_ttl( diff --git a/contracts/revenue_split/src/test.rs b/contracts/revenue_split/src/test.rs index c33fe165..fd166bf1 100644 --- a/contracts/revenue_split/src/test.rs +++ b/contracts/revenue_split/src/test.rs @@ -253,6 +253,45 @@ fn test_update_recipients_rejects_zero_share() { assert_eq!(result, Err(Ok(RevenueSplitError::ZeroBasisPoints))); } +#[test] +fn test_update_recipients_rejects_invalid_sum() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(RevenueSplitContract, ()); + let client = RevenueSplitContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let recipient1 = Address::generate(&env); + + let shares = Vec::from_array( + &env, + [RecipientShare { + destination: recipient1.clone(), + basis_points: 10000, + }], + ); + client.init(&admin, &shares); + + let recipient2 = Address::generate(&env); + let new_shares = Vec::from_array( + &env, + [ + RecipientShare { + destination: recipient1, + basis_points: 5000, + }, + RecipientShare { + destination: recipient2, + basis_points: 4000, + }, + ], + ); + + let result = client.try_update_recipients(&new_shares); + assert_eq!(result, Err(Ok(RevenueSplitError::BasisPointsSumMismatch))); +} + #[test] fn test_set_admin() { let env = Env::default(); @@ -277,6 +316,82 @@ fn test_set_admin() { assert_eq!(client.get_admin(), new_admin); } +#[test] +fn test_multi_asset_distribution_tracks_each_token() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_sequence_number(100); + + let token_admin = Address::generate(&env); + let (token_a, asset_a, token_client_a) = create_token_contract(&env, &token_admin); + let (token_b, asset_b, token_client_b) = create_token_contract(&env, &token_admin); + + let contract_id = env.register(RevenueSplitContract, ()); + let client = RevenueSplitContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let recipient = Address::generate(&env); + let shares = Vec::from_array( + &env, + [RecipientShare { + destination: recipient.clone(), + basis_points: 10000, + }], + ); + + client.init(&admin, &shares); + client.add_supported_asset(&token_a); + client.add_supported_asset(&token_b); + + let sender = Address::generate(&env); + asset_a.mint(&sender, &1000); + asset_b.mint(&sender, &2500); + + client.distribute(&token_a, &sender, &1000); + env.ledger().set_sequence_number(101); + client.distribute(&token_b, &sender, &2500); + + assert_eq!(token_client_a.balance(&recipient), 1000); + assert_eq!(token_client_b.balance(&recipient), 2500); + assert_eq!(client.get_total_distributed(&token_a), 1000); + assert_eq!(client.get_total_distributed(&token_b), 2500); + assert_eq!(client.get_distribution_count(), 2); +} + +#[test] +fn test_unsupported_asset_is_rejected_when_allowlist_configured() { + let env = Env::default(); + env.mock_all_auths(); + + let token_admin = Address::generate(&env); + let (supported_token, _, _) = create_token_contract(&env, &token_admin); + let (unsupported_token, asset_b, token_client_b) = create_token_contract(&env, &token_admin); + + let contract_id = env.register(RevenueSplitContract, ()); + let client = RevenueSplitContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let recipient = Address::generate(&env); + let shares = Vec::from_array( + &env, + [RecipientShare { + destination: recipient.clone(), + basis_points: 10000, + }], + ); + + client.init(&admin, &shares); + client.add_supported_asset(&supported_token); + + let sender = Address::generate(&env); + asset_b.mint(&sender, &1000); + + let result = client.try_distribute(&unsupported_token, &sender, &1000); + assert_eq!(result, Err(Ok(RevenueSplitError::UnsupportedAsset))); + assert_eq!(token_client_b.balance(&recipient), 0); + assert_eq!(client.get_total_distributed(&unsupported_token), 0); +} + // ══════════════════════════════════════════════════════════════════════════════ // ── LEDGER SEQUENCE VERIFICATION ────────────────────────────────────────────── // ══════════════════════════════════════════════════════════════════════════════