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
21 changes: 21 additions & 0 deletions backend/src/__tests__/rateLimiter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
8 changes: 8 additions & 0 deletions backend/src/controllers/rateLimitController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions backend/src/routes/rateLimitRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,7 @@ const router = Router();
* 200:
* description: Success
*/
router.get('/status', RateLimitController.getStatus);
router.get('/tiers', RateLimitController.getTiers);

export default router;
94 changes: 93 additions & 1 deletion contracts/orgusd/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]
Expand Down
34 changes: 34 additions & 0 deletions contracts/revenue_split/README.md
Original file line number Diff line number Diff line change
@@ -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.
113 changes: 112 additions & 1 deletion contracts/revenue_split/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub enum RevenueSplitError {
LedgerReplayDetected = 6,
UnauthorizedDistribution = 7,
ContractPaused = 8,
UnsupportedAsset = 9,
}

// ── Events ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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]
Expand All @@ -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)]
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<Address> {
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
Expand All @@ -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);
Expand Down Expand Up @@ -418,6 +487,47 @@ impl RevenueSplitContract {
);
}

fn load_supported_assets_or_empty(env: &Env) -> Vec<Address> {
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<Address>) {
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<Address>, 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
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading