Skip to content

Commit 259fdb0

Browse files
authored
Merge pull request #61 from OpenZeppelin/stellar-vault
Stellar vault
2 parents 71e90f9 + 9dd2daa commit 259fdb0

File tree

3 files changed

+381
-0
lines changed

3 files changed

+381
-0
lines changed

content/stellar-contracts/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ for access control and contract management.
1515
* **[Fungible Tokens](/stellar-contracts/tokens/fungible/fungible)**: Digital assets representing a fixed or dynamic supply of identical units.
1616
* **[Non-Fungible Tokens (NFTs)](/stellar-contracts/tokens/non-fungible/non-fungible)**: Unique digital assets with verifiable ownership.
1717
* **[Real World Assets (RWAs)](/stellar-contracts/tokens/rwa/rwa)**: Digital assets representing real-world assets.
18+
* **[Vault](/stellar-contracts/tokens/vault/vault)**: Digital assets representing a fixed or dynamic supply of identical units.
1819

1920
## Access Control
2021

Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
---
2+
title: Fungible Token Vault
3+
---
4+
5+
[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/vault)
6+
7+
The Fungible Token Vault extends the [Fungible Token](/stellar-contracts/tokens/fungible/fungible) and implements the ERC-4626 tokenized vault standard,
8+
enabling fungible tokens to represent shares in an underlying asset pool. The tokenized vault standard
9+
is the formalized interface for yield-bearing vaults that hold underlying assets. Vault shares enable
10+
hyperfungible collaterals in DeFi and remain fully compatible with standard fungible token operations.
11+
12+
This module allows users to deposit underlying assets in exchange for vault shares, and later redeem
13+
those shares for the underlying assets. The vault maintains a dynamic conversion rate between shares and
14+
assets based on the total supply of shares and total assets held by the vault contract.
15+
16+
## Overview
17+
18+
The [Vault](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/vault) module provides a complete implementation of tokenized vaults following the ERC-4626 standard. Vaults are useful for:
19+
20+
- **Yield-bearing tokens**: Represent shares in a yield-generating strategy
21+
- **Liquidity pools**: Pool assets together with automatic share calculation
22+
- **Asset management**: Manage a pool of assets with proportional ownership
23+
- **Wrapped tokens**: Create wrapped versions of tokens with additional features
24+
25+
The vault automatically handles:
26+
- Share-to-asset conversion with configurable precision
27+
- Deposit and withdrawal operations
28+
- Minting and redemption of shares
29+
- Preview functions for simulating operations
30+
31+
## Key Concepts
32+
33+
### Shares vs Assets
34+
35+
- **Assets**: The underlying token that the vault manages (e.g., USDC, XLM)
36+
- **Shares**: The Token Vaults that represent proportional ownership of the assets
37+
38+
When assets are deposited into a vault, shares are minted to the depositor.
39+
The number of shares minted depends on the current exchange rate, which is determined by:
40+
41+
```
42+
shares = (assets × totalSupply) / totalAssets
43+
```
44+
45+
When withdrawing or redeeming, the reverse calculation applies:
46+
47+
```
48+
assets = (shares × totalAssets) / totalSupply
49+
```
50+
51+
### Virtual Decimals Offset
52+
53+
The vault uses a "virtual decimals offset" to add extra precision to share calculations.
54+
This helps prevent rounding errors and improves the accuracy of share-to-asset conversions,
55+
especially when the vault has few assets or shares. It's also a key defense mechanism against
56+
[inflation attacks](#inflation-precision-attacks).
57+
58+
The offset adds virtual shares and assets to the conversion formula:
59+
60+
```
61+
shares = (assets × (totalSupply + 10^offset)) / (totalAssets + 1)
62+
```
63+
64+
The offset is bounded to a maximum of 10 with both security and UX taken into account.
65+
Values higher than 10 provide minimal practical benefits and may cause overflow errors.
66+
67+
## Rounding Behavior
68+
69+
The vault implements specific rounding behavior to protect against being drained through repeated rounding exploits.
70+
Without proper rounding, an attacker could exploit precision loss to extract more assets than they deposited by
71+
performing many small operations where rounding errors accumulate in their favor.
72+
73+
To prevent this:
74+
75+
- **Deposit/Redeem**: Rounds **down** (depositor receives slightly fewer shares/assets)
76+
- **Mint/Withdraw**: Rounds **up** (depositor provides slightly more assets/shares)
77+
78+
This ensures the vault always retains a slight advantage in conversions, making such attacks unprofitable.
79+
80+
| Operation | Input | Output | Rounding Direction |
81+
| ---------- | ------ | ------ | ---------------------------- |
82+
| `deposit` | assets | shares | Down (fewer shares) |
83+
| `mint` | shares | assets | Up (more assets required) |
84+
| `withdraw` | assets | shares | Up (more shares burned) |
85+
| `redeem` | shares | assets | Down (fewer assets received) |
86+
87+
## Security Considerations
88+
89+
### Initialization
90+
91+
The vault **MUST** be properly initialized before use:
92+
93+
1. Call `Vault::set_asset(e, asset)` to set the underlying asset
94+
2. Call `Vault::set_decimals_offset(e, offset)` to set the decimals offset
95+
3. Initialize metadata with `Base::set_metadata()`
96+
97+
These should typically be done in the constructor. Once set, the asset address and decimals offset are **immutable**.
98+
99+
### Decimal Offset Limits
100+
101+
The decimals offset is limited to a maximum of 10 to prevent:
102+
- Overflow errors in calculations
103+
- Excessive precision that provides no practical benefit
104+
- Poor user experience with unnecessarily large numbers
105+
106+
If a higher offset is required, a custom version of `set_decimals_offset()` must be implemented.
107+
108+
### Inflation (Precision) Attacks
109+
110+
The virtual decimals offset helps protect against inflation attacks where an attacker:
111+
1. Deposits 1 stroop to get the first share (becoming the sole shareholder)
112+
2. **Donates** (not deposits) an enormous amount of assets directly to the vault contract via a direct transfer, without receiving any shares in return. This inflates the vault's total assets while keeping total shares at 1, making that single share worth an enormous amount
113+
3. When a legitimate user tries to deposit (e.g., 1000 stroops), the share calculation rounds down to 0 shares because their deposit is negligible compared to the inflated vault balance. The user loses their deposit while receiving nothing
114+
115+
For example: If the attacker donates 1,000,000 stroops after their initial 1 stroop deposit, the vault has 1,000,001 total assets and 1 total share. A user depositing 1000 stroops would receive `(1000 × 1) / 1,000,001 = 0.000999` shares, which rounds down to 0.
116+
117+
The offset adds virtual shares and assets to the conversion formula, making such attacks economically infeasible by ensuring the denominator is never so small that legitimate deposits round to zero.
118+
119+
For more details about the mechanics of this attack, see the [OpenZeppelin ERC-4626 security documentation](https://docs.openzeppelin.com/contracts/5.x/erc4626#security-concern-inflation-attack).
120+
121+
### Custom Authorization
122+
123+
Custom authorization logic can be implemented as needed:
124+
125+
```rust
126+
fn deposit(
127+
e: &Env,
128+
assets: i128,
129+
receiver: Address,
130+
from: Address,
131+
operator: Address,
132+
) -> i128 {
133+
// Custom authorization: only allow deposits from whitelisted addresses
134+
if !is_whitelisted(e, &from) {
135+
panic_with_error!(e, Error::NotWhitelisted);
136+
}
137+
138+
operator.require_auth();
139+
Vault::deposit(e, assets, receiver, from, operator)
140+
}
141+
```
142+
143+
## Compatibility and Compliance
144+
145+
The vault module implements the ERC-4626 tokenized vault standard with one minor deviation (see Security Considerations).
146+
147+
### ERC-4626 Deviation
148+
149+
<Callout type="warning">
150+
The `query_asset()` function will **panic if the asset address is not set**, whereas ERC-4626 requires it to never revert.
151+
152+
**Rationale**: Soroban doesn't have a "zero address" concept like EVM. Returning `Option<Address>` would break ERC-4626 compatibility.
153+
154+
**Mitigation**: Always initialize the vault properly in the constructor. Once initialized, `query_asset()` will never panic during normal operations.
155+
</Callout>
156+
157+
Aside from this deviation, the vault implementation for Soroban provides:
158+
159+
- **Cross-ecosystem familiarity**: Ethereum developers will recognize the interface
160+
- **Standard compliance**: Compatible with ERC-4626 tooling and integrations
161+
162+
## Usage
163+
164+
### Basic Implementation
165+
166+
To create a vault contract, implement both the `FungibleToken` and `FungibleVault` traits:
167+
168+
```rust
169+
use soroban_sdk::{contract, contractimpl, Address, Env, String};
170+
use stellar_macros::default_impl;
171+
use stellar_tokens::{
172+
fungible::{Base, FungibleToken},
173+
vault::{FungibleVault, Vault},
174+
};
175+
176+
#[contract]
177+
pub struct VaultContract;
178+
179+
#[contractimpl]
180+
impl VaultContract {
181+
pub fn __constructor(e: &Env, asset: Address, decimals_offset: u32) {
182+
// Set the underlying asset address (immutable after initialization)
183+
Vault::set_asset(e, asset);
184+
185+
// Set the decimals offset for precision (immutable after initialization)
186+
Vault::set_decimals_offset(e, decimals_offset);
187+
188+
// Initialize token metadata
189+
// Note: Vault overrides the decimals function, so set offset first
190+
Base::set_metadata(
191+
e,
192+
Vault::decimals(e),
193+
String::from_str(e, "Vault Token"),
194+
String::from_str(e, "VLT"),
195+
);
196+
}
197+
}
198+
199+
#[default_impl]
200+
#[contractimpl]
201+
impl FungibleToken for VaultContract {
202+
type ContractType = Vault;
203+
204+
fn decimals(e: &Env) -> u32 {
205+
Vault::decimals(e)
206+
}
207+
}
208+
209+
#[contractimpl]
210+
impl FungibleVault for VaultContract {
211+
fn deposit(
212+
e: &Env,
213+
assets: i128,
214+
receiver: Address,
215+
from: Address,
216+
operator: Address,
217+
) -> i128 {
218+
operator.require_auth();
219+
Vault::deposit(e, assets, receiver, from, operator)
220+
}
221+
222+
fn withdraw(
223+
e: &Env,
224+
assets: i128,
225+
receiver: Address,
226+
owner: Address,
227+
operator: Address,
228+
) -> i128 {
229+
operator.require_auth();
230+
Vault::withdraw(e, assets, receiver, owner, operator)
231+
}
232+
233+
// Implement other required methods...
234+
}
235+
```
236+
237+
### Initialization
238+
239+
The vault **must** be properly initialized in the constructor:
240+
241+
1. **Set the underlying asset**: Call `Vault::set_asset(e, asset)` with the address of the token contract that the vault will manage
242+
2. **Set the decimals offset**: Call `Vault::set_decimals_offset(e, offset)` to configure precision (0-10 recommended)
243+
3. **Initialize metadata**: Call `Base::set_metadata()` with appropriate token information
244+
245+
**Important**: The asset address and decimals offset are immutable once set and cannot be changed.
246+
247+
### Core Operations
248+
249+
#### Depositing Assets
250+
251+
Users can deposit underlying assets to receive vault shares:
252+
253+
```rust
254+
// Deposit 1000 assets and receive shares
255+
let shares_received = vault_client.deposit(
256+
&1000, // Amount of assets to deposit
257+
&user_address, // Address to receive shares
258+
&user_address, // Address providing assets
259+
&user_address, // Operator (requires auth)
260+
);
261+
```
262+
263+
Alternatively, mint a specific amount of shares:
264+
265+
```rust
266+
// Mint exactly 500 shares by depositing required assets
267+
let assets_required = vault_client.mint(
268+
&500, // Amount of shares to mint
269+
&user_address, // Address to receive shares
270+
&user_address, // Address providing assets
271+
&user_address, // Operator (requires auth)
272+
);
273+
```
274+
275+
#### Withdrawing Assets
276+
277+
Users can withdraw assets by burning their shares:
278+
279+
```rust
280+
// Withdraw 500 assets by burning required shares
281+
let shares_burned = vault_client.withdraw(
282+
&500, // Amount of assets to withdraw
283+
&user_address, // Address to receive assets
284+
&user_address, // Owner of shares
285+
&user_address, // Operator (requires auth)
286+
);
287+
```
288+
289+
Or redeem a specific amount of shares:
290+
291+
```rust
292+
// Redeem 200 shares for underlying assets
293+
let assets_received = vault_client.redeem(
294+
&200, // Amount of shares to redeem
295+
&user_address, // Address to receive assets
296+
&user_address, // Owner of shares
297+
&user_address, // Operator (requires auth)
298+
);
299+
```
300+
301+
### Preview Functions
302+
303+
Preview functions allow you to simulate operations without executing them:
304+
305+
```rust
306+
let expected_shares = vault_client.preview_deposit(&1000);
307+
308+
let required_assets = vault_client.preview_mint(&500);
309+
310+
let shares_to_burn = vault_client.preview_withdraw(&500);
311+
312+
let expected_assets = vault_client.preview_redeem(&200);
313+
```
314+
315+
### Conversion Functions
316+
317+
Convert between assets and shares at the current exchange rate:
318+
319+
```rust
320+
// Convert assets to shares
321+
let shares = vault_client.convert_to_shares(&1000);
322+
323+
// Convert shares to assets
324+
let assets = vault_client.convert_to_assets(&500);
325+
```
326+
327+
### Query Functions
328+
329+
Check vault state and limits:
330+
331+
```rust
332+
// Get the underlying asset address
333+
let asset_address = vault_client.query_asset();
334+
335+
// Get total assets held by the vault
336+
let total_assets = vault_client.total_assets();
337+
338+
// Check maximum amounts for operations
339+
let max_deposit = vault_client.max_deposit(&user_address);
340+
let max_mint = vault_client.max_mint(&user_address);
341+
let max_withdraw = vault_client.max_withdraw(&user_address);
342+
let max_redeem = vault_client.max_redeem(&user_address);
343+
```
344+
345+
### Operator Pattern
346+
347+
The vault supports an operator pattern where one address can perform operations on behalf of another:
348+
349+
```rust
350+
// User approves operator to spend their assets on the underlying token
351+
asset_client.approve(&user, &operator, &1000, &expiration_ledger);
352+
353+
// Operator deposits user's assets to a receiver
354+
vault_client.deposit(
355+
&1000,
356+
&receiver, // Receives the shares
357+
&user, // Provides the assets
358+
&operator, // Performs the operation (requires auth)
359+
);
360+
```
361+
362+
For withdrawals, the operator must have allowance on the **vault shares**:
363+
364+
```rust
365+
// User approves operator to spend their vault shares
366+
vault_client.approve(&user, &operator, &500, &expiration_ledger);
367+
368+
// Operator withdraws on behalf of user
369+
vault_client.withdraw(
370+
&500,
371+
&receiver, // Receives the assets
372+
&user, // Owns the shares
373+
&operator, // Performs the operation (requires auth)
374+
);
375+
```

src/navigation/stellar.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@
6262
"type": "page",
6363
"name": "RWA",
6464
"url": "/stellar-contracts/tokens/rwa/rwa"
65+
},
66+
{
67+
"type": "page",
68+
"name": "Vault",
69+
"url": "/stellar-contracts/tokens/vault/vault"
6570
}
6671
]
6772
},

0 commit comments

Comments
 (0)