Skip to content

Commit 8caf176

Browse files
committed
Implement RPC config, API docs workflow, DAO governance, and BENJI connector.
Adds custom Soroban RPC environment configuration in the frontend, introduces basic on-chain governance for strategy updates, wires BENJI strategy yield reporting, and sets up automated API documentation generation. Made-with: Cursor
1 parent 35752bc commit 8caf176

42 files changed

Lines changed: 2415 additions & 5 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/docs.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: Generate API Documentation
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- main
8+
9+
jobs:
10+
docs:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Checkout
14+
uses: actions/checkout@v4
15+
16+
- name: Setup Rust
17+
uses: dtolnay/rust-toolchain@stable
18+
19+
- name: Generate Rust docs
20+
run: cargo doc -p vault --no-deps
21+
22+
- name: Setup Node
23+
uses: actions/setup-node@v4
24+
with:
25+
node-version: 20
26+
cache: npm
27+
cache-dependency-path: frontend/package-lock.json
28+
29+
- name: Install frontend dependencies
30+
run: npm install
31+
working-directory: frontend
32+
33+
- name: Generate frontend API docs
34+
run: npm run docs:api
35+
working-directory: frontend

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,36 @@ npm run dev
3636

3737
Navigate to `http://localhost:5173` to interact with the local UI.
3838

39+
### 3. Custom Soroban RPC Configuration
40+
41+
Create a frontend environment file from the example:
42+
43+
```bash
44+
cd frontend
45+
cp .env.example .env
46+
```
47+
48+
Set:
49+
50+
- `VITE_SOROBAN_RPC_URL` (custom RPC endpoint, optional)
51+
- `VITE_STELLAR_NETWORK_PASSPHRASE` (network passphrase)
52+
- `VITE_VAULT_CONTRACT_ID` (deployed vault contract ID)
53+
54+
If `VITE_SOROBAN_RPC_URL` is not set, the app defaults to Stellar testnet RPC.
55+
56+
## API Documentation
57+
58+
Generate contract and frontend API docs:
59+
60+
```bash
61+
cargo doc -p vault --no-deps
62+
cd frontend
63+
npm install
64+
npm run docs:api
65+
```
66+
67+
See `docs/api/README.md` for output locations.
68+
3969
## Roadmap (Phases)
4070
- **Phase 1**: Planning, Documentation, and Frontend UI Baseline (Completed)
4171
- **Phase 2**: Soroban Smart Contract Implementation in Rust (Completed)

contracts/vault/src/lib.rs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,23 @@ pub enum DataKey {
1313
TotalShares,
1414
TotalAssets,
1515
Admin,
16+
DaoThreshold,
17+
ProposalNonce,
18+
BenjiStrategy,
19+
Proposal(u32),
20+
Vote(u32, Address),
1621
ShareBalance(Address),
1722
}
1823

24+
#[contracttype]
25+
#[derive(Clone, Debug, Eq, PartialEq)]
26+
pub struct StrategyProposal {
27+
pub strategy: Address,
28+
pub yes_votes: i128,
29+
pub no_votes: i128,
30+
pub executed: bool,
31+
}
32+
1933
#[contract]
2034
pub struct YieldVault;
2135

@@ -31,6 +45,8 @@ impl YieldVault {
3145
env.storage().instance().set(&DataKey::Token, &token);
3246
env.storage().instance().set(&DataKey::TotalShares, &0i128);
3347
env.storage().instance().set(&DataKey::TotalAssets, &0i128);
48+
env.storage().instance().set(&DataKey::DaoThreshold, &1i128);
49+
env.storage().instance().set(&DataKey::ProposalNonce, &0u32);
3450
}
3551

3652
/// Read the underlying token address.
@@ -53,6 +69,83 @@ impl YieldVault {
5369
env.storage().instance().get(&DataKey::ShareBalance(user)).unwrap_or(0)
5470
}
5571

72+
/// Read configured BENJI strategy contract address.
73+
pub fn benji_strategy(env: Env) -> Address {
74+
env.storage().instance().get(&DataKey::BenjiStrategy).unwrap()
75+
}
76+
77+
/// Configure DAO quorum threshold. Only admin can update this parameter.
78+
pub fn set_dao_threshold(env: Env, threshold: i128) {
79+
let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
80+
admin.require_auth();
81+
if threshold <= 0 {
82+
panic!("threshold must be > 0");
83+
}
84+
env.storage().instance().set(&DataKey::DaoThreshold, &threshold);
85+
}
86+
87+
/// Create a proposal to update the BENJI strategy connector address.
88+
pub fn create_strategy_proposal(env: Env, proposer: Address, strategy: Address) -> u32 {
89+
proposer.require_auth();
90+
let mut next_nonce: u32 = env.storage().instance().get(&DataKey::ProposalNonce).unwrap_or(0);
91+
next_nonce += 1;
92+
env.storage().instance().set(&DataKey::ProposalNonce, &next_nonce);
93+
94+
let proposal = StrategyProposal {
95+
strategy,
96+
yes_votes: 0,
97+
no_votes: 0,
98+
executed: false,
99+
};
100+
env.storage().instance().set(&DataKey::Proposal(next_nonce), &proposal);
101+
next_nonce
102+
}
103+
104+
/// Vote on a proposal with a given voting weight.
105+
pub fn vote_on_proposal(env: Env, voter: Address, proposal_id: u32, support: bool, weight: i128) {
106+
voter.require_auth();
107+
if weight <= 0 {
108+
panic!("weight must be > 0");
109+
}
110+
if env.storage().instance().has(&DataKey::Vote(proposal_id, voter.clone())) {
111+
panic!("duplicate vote");
112+
}
113+
114+
let mut proposal: StrategyProposal = env.storage().instance().get(&DataKey::Proposal(proposal_id)).unwrap();
115+
if proposal.executed {
116+
panic!("proposal already executed");
117+
}
118+
119+
if support {
120+
proposal.yes_votes += weight;
121+
} else {
122+
proposal.no_votes += weight;
123+
}
124+
125+
env.storage().instance().set(&DataKey::Proposal(proposal_id), &proposal);
126+
env.storage().instance().set(&DataKey::Vote(proposal_id, voter), &true);
127+
}
128+
129+
/// Execute a strategy proposal once it reaches threshold and has majority support.
130+
pub fn execute_strategy_proposal(env: Env, proposal_id: u32) {
131+
let mut proposal: StrategyProposal = env.storage().instance().get(&DataKey::Proposal(proposal_id)).unwrap();
132+
if proposal.executed {
133+
panic!("proposal already executed");
134+
}
135+
136+
let threshold: i128 = env.storage().instance().get(&DataKey::DaoThreshold).unwrap_or(1);
137+
if proposal.yes_votes < threshold {
138+
panic!("quorum not reached");
139+
}
140+
if proposal.yes_votes <= proposal.no_votes {
141+
panic!("proposal rejected");
142+
}
143+
144+
env.storage().instance().set(&DataKey::BenjiStrategy, &proposal.strategy);
145+
proposal.executed = true;
146+
env.storage().instance().set(&DataKey::Proposal(proposal_id), &proposal);
147+
}
148+
56149
/// Calculates the number of shares given an asset amount based on the current exchange rate.
57150
pub fn calculate_shares(env: Env, assets: i128) -> i128 {
58151
let ts = Self::total_shares(env.clone());
@@ -145,4 +238,23 @@ impl YieldVault {
145238
let ta = Self::total_assets(env.clone());
146239
env.storage().instance().set(&DataKey::TotalAssets, &(ta + amount));
147240
}
241+
242+
/// BENJI strategy connector callback for reporting harvested yield into the vault.
243+
pub fn report_benji_yield(env: Env, strategy: Address, amount: i128) {
244+
strategy.require_auth();
245+
if amount <= 0 {
246+
panic!("yield amount must be > 0");
247+
}
248+
let configured: Address = env.storage().instance().get(&DataKey::BenjiStrategy).unwrap();
249+
if strategy != configured {
250+
panic!("unauthorized strategy");
251+
}
252+
253+
let token_addr = Self::token(env.clone());
254+
let token_client = token::Client::new(&env, &token_addr);
255+
token_client.transfer(&strategy, &env.current_contract_address(), &amount);
256+
257+
let ta = Self::total_assets(env.clone());
258+
env.storage().instance().set(&DataKey::TotalAssets, &(ta + amount));
259+
}
148260
}

contracts/vault/src/test.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,61 @@ fn test_vault_flow() {
7272
assert_eq!(withdrawn_user2, 110);
7373
assert_eq!(usdc.balance(&user2), 910); // 800 + 110
7474
}
75+
76+
#[test]
77+
fn test_governance_sets_benji_strategy() {
78+
let env = Env::default();
79+
env.mock_all_auths();
80+
81+
let admin = Address::generate(&env);
82+
let voter_1 = Address::generate(&env);
83+
let voter_2 = Address::generate(&env);
84+
let benji_strategy = Address::generate(&env);
85+
86+
let token_admin = Address::generate(&env);
87+
let usdc = create_token_contract(&env, &token_admin);
88+
89+
let vault_id = env.register(YieldVault, ());
90+
let vault = YieldVaultClient::new(&env, &vault_id);
91+
vault.initialize(&admin, &usdc.address);
92+
93+
vault.set_dao_threshold(&2);
94+
95+
let proposal_id = vault.create_strategy_proposal(&admin, &benji_strategy);
96+
vault.vote_on_proposal(&voter_1, &proposal_id, &true, &1);
97+
vault.vote_on_proposal(&voter_2, &proposal_id, &true, &1);
98+
vault.execute_strategy_proposal(&proposal_id);
99+
100+
assert_eq!(vault.benji_strategy(), benji_strategy);
101+
}
102+
103+
#[test]
104+
fn test_benji_connector_reports_yield() {
105+
let env = Env::default();
106+
env.mock_all_auths();
107+
108+
let admin = Address::generate(&env);
109+
let user = Address::generate(&env);
110+
let benji_strategy = Address::generate(&env);
111+
112+
let token_admin = Address::generate(&env);
113+
let usdc = create_token_contract(&env, &token_admin);
114+
let usdc_admin_client = token::StellarAssetClient::new(&env, &usdc.address);
115+
usdc_admin_client.mint(&user, &1000);
116+
usdc_admin_client.mint(&benji_strategy, &100);
117+
118+
let vault_id = env.register(YieldVault, ());
119+
let vault = YieldVaultClient::new(&env, &vault_id);
120+
vault.initialize(&admin, &usdc.address);
121+
122+
vault.set_dao_threshold(&1);
123+
let proposal_id = vault.create_strategy_proposal(&admin, &benji_strategy);
124+
vault.vote_on_proposal(&admin, &proposal_id, &true, &1);
125+
vault.execute_strategy_proposal(&proposal_id);
126+
127+
vault.deposit(&user, &500);
128+
assert_eq!(vault.total_assets(), 500);
129+
130+
vault.report_benji_yield(&benji_strategy, &40);
131+
assert_eq!(vault.total_assets(), 540);
132+
}

docs/api/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# API Documentation
2+
3+
This project exposes APIs in two layers:
4+
5+
- Soroban smart contract API (`contracts/vault`)
6+
- Frontend TypeScript API (`frontend/src`)
7+
8+
## Generate docs locally
9+
10+
### 1) Soroban contract docs
11+
12+
```bash
13+
cargo doc -p vault --no-deps
14+
```
15+
16+
### 2) Frontend API docs
17+
18+
```bash
19+
cd frontend
20+
npm install
21+
npm run docs:api
22+
```
23+
24+
Generated output:
25+
26+
- Rust docs: `target/doc`
27+
- Frontend docs: `docs/api/frontend`

docs/api/frontend/.nojekyll

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
window.hierarchyData = "eJyrVirKzy8pVrKKjtVRKkpNy0lNLsnMzytWsqqurQUAmx4Kpg=="
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
:root {
2+
--light-hl-0: #AF00DB;
3+
--dark-hl-0: #C586C0;
4+
--light-hl-1: #000000;
5+
--dark-hl-1: #D4D4D4;
6+
--light-hl-2: #795E26;
7+
--dark-hl-2: #DCDCAA;
8+
--light-hl-3: #A31515;
9+
--dark-hl-3: #CE9178;
10+
--light-hl-4: #001080;
11+
--dark-hl-4: #9CDCFE;
12+
--light-hl-5: #008000;
13+
--dark-hl-5: #6A9955;
14+
--light-code-background: #FFFFFF;
15+
--dark-code-background: #1E1E1E;
16+
}
17+
18+
@media (prefers-color-scheme: light) { :root {
19+
--hl-0: var(--light-hl-0);
20+
--hl-1: var(--light-hl-1);
21+
--hl-2: var(--light-hl-2);
22+
--hl-3: var(--light-hl-3);
23+
--hl-4: var(--light-hl-4);
24+
--hl-5: var(--light-hl-5);
25+
--code-background: var(--light-code-background);
26+
} }
27+
28+
@media (prefers-color-scheme: dark) { :root {
29+
--hl-0: var(--dark-hl-0);
30+
--hl-1: var(--dark-hl-1);
31+
--hl-2: var(--dark-hl-2);
32+
--hl-3: var(--dark-hl-3);
33+
--hl-4: var(--dark-hl-4);
34+
--hl-5: var(--dark-hl-5);
35+
--code-background: var(--dark-code-background);
36+
} }
37+
38+
:root[data-theme='light'] {
39+
--hl-0: var(--light-hl-0);
40+
--hl-1: var(--light-hl-1);
41+
--hl-2: var(--light-hl-2);
42+
--hl-3: var(--light-hl-3);
43+
--hl-4: var(--light-hl-4);
44+
--hl-5: var(--light-hl-5);
45+
--code-background: var(--light-code-background);
46+
}
47+
48+
:root[data-theme='dark'] {
49+
--hl-0: var(--dark-hl-0);
50+
--hl-1: var(--dark-hl-1);
51+
--hl-2: var(--dark-hl-2);
52+
--hl-3: var(--dark-hl-3);
53+
--hl-4: var(--dark-hl-4);
54+
--hl-5: var(--dark-hl-5);
55+
--code-background: var(--dark-code-background);
56+
}
57+
58+
.hl-0 { color: var(--hl-0); }
59+
.hl-1 { color: var(--hl-1); }
60+
.hl-2 { color: var(--hl-2); }
61+
.hl-3 { color: var(--hl-3); }
62+
.hl-4 { color: var(--hl-4); }
63+
.hl-5 { color: var(--hl-5); }
64+
pre, code { background: var(--code-background); }

0 commit comments

Comments
 (0)