The LiquiFact escrow contract provides configurable limits on the number of distinct investor addresses that can contribute to an invoice escrow. This feature helps manage risk, compliance requirements, and operational complexity by enforcing Sybil-limited counter semantics.
Important: The investor cap limits distinct chain addresses, not real-world persons. The contract does not implement Sybil resistance mechanisms - it simply counts unique wallet addresses. This is explicitly documented as:
- What is limited: Distinct blockchain addresses (public keys)
- What is NOT limited: Real-world individuals or entities
- Assumption: One address = one investor for operational purposes
DataKey::MaxUniqueInvestorsCap: Optionalu32cap on distinct investorsDataKey::MaxPerInvestorCap: Optionali128cap on cumulative principal per investor addressDataKey::UniqueFunderCount: Current count of distinct funders (initialized to 0)
The UniqueFunderCount increments only when an address makes its first non-zero contribution:
// In fund_impl - simplified logic
let prev: i128 = env.storage().instance().get(&contribution_key).unwrap_or(0);
if prev == 0 {
// First time this address is funding
if let Some(cap) = max_unique_investors_cap {
let cur: u32 = env.storage().instance().get(&DataKey::UniqueFunderCount).unwrap_or(0);
assert!(cur < cap, "unique investor cap reached");
}
}
// ... funding logic ...
if prev == 0 {
// Increment counter after successful funding
let cur: u32 = env.storage().instance().get(&DataKey::UniqueFunderCount).unwrap_or(0);
env.storage().instance().set(&DataKey::UniqueFunderCount, &(cur + 1));
}- When checked: Before processing a new investor's first contribution
- What is checked:
current_unique_funders < configured_cap - Panic message:
"unique investor cap reached" - Edge case: Existing investors can always add more principal (doesn't count against cap)
- When checked: On every deposit, for both first-time and returning investors
- What is checked:
previous_contribution + amount <= configured_per_investor_cap - Panic message:
"investor contribution exceeds max_per_investor cap" - Edge case: A returning investor cannot exceed their configured cap across repeated deposits
The cap is set during escrow initialization via the max_unique_investors parameter:
pub fn init(
// ... other parameters
max_unique_investors: Option<u32>,
max_per_investor: Option<i128>,
) -> InvoiceEscrowNoneformax_unique_investors: No distinct-investor cap (unlimited investors)Some(n)formax_unique_investors: Cap ofndistinct investorsNoneformax_per_investor: No per-investor cap (unlimited principal per address)Some(x)formax_per_investor: Immutable maximum cumulative principal per investor address- Validation: Both caps must be positive if configured (
> 0)
Returns the configured cap, or None if unlimited.
Returns the current count of distinct funders.
Admin-only: reduces the configured cap while the escrow is open (status 0).
- Requires admin authorization.
- Only permitted when a cap was configured at init.
new_capmust satisfyunique_funder_count <= new_cap < old_cap.- Rejects raising the cap or imposing a cap on an unlimited escrow.
- Emits
MaxUniqueInvestorsCapLowered(inv_cap) for indexers. - Returns the stored cap after update (same as
get_max_unique_investors_cap()).
Admin-only: increases the configured cap while the escrow is open (status 0).
- Requires admin authorization.
- Only permitted when a cap was configured at init.
new_capmust satisfynew_cap > old_cap.- Emits
MaxUniqueInvestorsCapRaised(raise_cap) for indexers. - Returns the stored cap after update (same as
get_max_unique_investors_cap()).
// Cap of 10 investors
client.init(
&admin,
&invoice_id,
&sme,
&amount,
&yield_bps,
&maturity,
&funding_token,
®istry,
&treasury,
&yield_tiers,
&min_contribution,
&Some(10u32), // Max 10 investors
&Some(100_000_000_000i128), // Max 100 billion units per investor
);// Unlimited investors
client.init(
&admin,
&invoice_id,
&sme,
&amount,
&yield_bps,
&maturity,
&funding_token,
®istry,
&treasury,
&yield_tiers,
&min_contribution,
&None, // No distinct-investor cap
&None, // No per-investor cap
);// Investor 1 funds first time
client.fund(&investor1, &1000);
// unique_funder_count = 1
// Same investor funds again
client.fund(&investor1, &500);
// unique_funder_count = 1 (unchanged)// Cap = 2, 2 investors have funded
// unique_funder_count = 2
// New investor tries to fund
client.fund(&investor3, &1000); // PANICS: "unique investor cap reached"// Address with 0 contribution (not counted)
assert_eq!(client.get_contribution(&investor), 0);
assert_eq!(client.get_unique_funder_count(), 0);
// First non-zero contribution
client.fund(&investor, &1000);
assert_eq!(client.get_unique_funder_count(), 1);The cap works independently of the minimum contribution floor:
client.init(
// ...
&min_contribution: Some(1000),
&max_unique_investors: Some(5),
);Both validations are applied:
- Amount ≥ min_contribution
- unique_funder_count < max_unique_investors (for new investors)
For the funding floor:
- Deposits below
min_contributionare rejected per call. - Deposits exactly equal to
min_contributionare accepted. - Follow-on deposits from an existing investor still must satisfy the same per-call floor.
For the per-investor cap:
- Cumulative funding for one investor may equal
max_per_investor. - Any deposit that would raise the cumulative contribution above the cap is rejected.
- The cap is enforced across multiple
fund/fund_with_commitmentcalls for the same investor.
For the unique investor cap:
- Distinct first-time funders are counted until the configured
max_unique_investorsis reached. - Funding from the last allowed unique investor is accepted.
- A new address attempting to fund after the cap is reached is rejected.
- Follow-on funding by an already-counted investor continues to succeed even after the distinct-investor cap is reached.
The cap applies to both fund() and fund_with_commitment():
// First investor with commitment
client.fund_with_commitment(&investor1, &1000, &100);
// unique_funder_count = 1
// Second investor regular fund
client.fund(&investor2, &1000);
// unique_funder_count = 2The cap is checked AFTER allowlist validation:
// Process order for new investor:
// 1. Check allowlist (if active)
// 2. Check cap (if configured)
// 3. Check min contribution floor
// 4. Process funding- Cap enforcement: Strictly enforced with panic on violation
- Counter accuracy: Atomic operations prevent race conditions
- Re-funding safety: Existing investors can always add more principal
- Cap adjustment: Admin may lower or raise the cap while open via
lower_max_unique_investorsorraise_max_unique_investors.
- Sybil resistance: No mechanism to prevent one person from using multiple addresses
- Identity verification: No KYC/AML integration
- Unlimited to Capped: Cannot impose a cap on an unlimited escrow after initialization.
- Cap lowering below enrolled funders: Rejected to preserve the retroactive-cap invariant
Per escrow/src/external_calls.rs, the cap system assumes:
- Well-behaved tokens: Standard SEP-41 compliance
- No fee-on-transfer: Amounts received match amounts sent
- No rebase tokens: Stable accounting for contribution tracking
Malicious token contracts could theoretically interfere with contribution accounting, but this is explicitly out of scope for the cap system.
The implementation includes comprehensive tests covering:
- Counter initialization to zero
- Increment on first investor
- No increment on re-funding same address
- Multiple distinct investors
- Cap validation at initialization
- Enforcement at limit
- Panic on excess investors
- Clear error messages
- Zero cap validation (should panic)
- Exact limit behavior
- Large contributions with small caps
- Interaction with minimum contribution floors
fund()vsfund_with_commitment()behavior- Tiered yield system compatibility
- Allowlist system interaction
The investor cap features were added in schema version 3:
/// | Version | Summary | Upgrade path |
/// |---------|---------|-------------|
/// | 3 | Added `FundingCloseSnapshot`, `MinContributionFloor`, `MaxUniqueInvestorsCap`, `UniqueFunderCount` | Additive keys — old instances return defaults |
- Old instances: Return
Nonefor cap,0for counter - No migration required: Additive keys with safe defaults
- New instances: Can configure caps during initialization
Consider these factors when setting investor caps:
- Compliance requirements: Regulatory limits on investor counts
- Operational capacity: Ability to handle investor relationships
- Risk management: Concentration risk vs. diversification benefits
- Target raise size: Balance cap with funding target
Monitor these metrics during live operation:
unique_funder_countvs.max_unique_investors_cap- Time to reach cap (if any)
- Average contribution per unique investor
- Re-funding patterns (existing investors adding more)
If cap exhaustion becomes an issue while the escrow is still open:
- Adjust the cap: Admin may call
raise_max_unique_investorsto increase the limit. - New escrow deployment: Required for a higher cap or to change unlimited → capped.
- Off-chain coordination: Direct investors to new escrow instances when needed.
// Recommended: Set caps based on realistic operational capacity
let reasonable_cap = match target_amount {
0..=1_000_000 => Some(50), // Small deals: more investors
1_000_001..=10_000_000 => Some(20), // Medium deals: moderate investors
_ => Some(10), // Large deals: fewer investors
};// Client-side: Check cap before attempting funding
if let (Some(cap), current_count) = (client.get_max_unique_investors_cap(), client.get_unique_funder_count()) {
if current_count >= cap {
return Err(InvestorCapExceeded);
}
}
client.fund(&investor, &amount);When deploying capped escrows:
- Clearly communicate caps to potential investors
- Document rationale for cap selection
- Provide alternative escrows if caps may be reached
- Monitor cap utilization in real-time
The MaxUniqueInvestorsCap and UniqueFunderCount functionality provides a robust, Sybil-limited mechanism for controlling investor participation in LiquiFact escrows. While it doesn't prevent Sybil attacks, it offers operational control and compliance benefits with clear semantics and comprehensive edge case handling.
The implementation prioritizes safety and predictability, with strict enforcement and clear error messages. Organizations should carefully consider their cap requirements during deployment; caps can be adjusted while open via admin-only commands.