Skip to content

feat: add SUSD3Fund (staked USD3 integration)#198

Closed
maximebrugel wants to merge 9 commits into
fund-batch-2from
susd3-fund
Closed

feat: add SUSD3Fund (staked USD3 integration)#198
maximebrugel wants to merge 9 commits into
fund-batch-2from
susd3-fund

Conversation

@maximebrugel

Copy link
Copy Markdown
Contributor

Summary

Adds SUSD3Fund — a new fund wrapping the sUSD3 subordinate tranche (3Jane's staked USD3) behind the IFund state machine. The fund's accounting asset is USDC, but sUSD3 stakes USD3, which itself wraps USDC — so the fund performs a double ERC-4626 conversion (USDC → USD3 → sUSD3 on deposit, and the reverse on redeem).

Modeled on ParetoFund (synchronous deposit / gated-async redeem), with CentrifugeFund's operator-abort + partial-unlock patterns for recovery. Multiple instances are deployed to run concurrent settlements (one active order each), all backed by a single shared wsUSD3 WrappedAsset.

Design

  • Deposit — synchronous (sUSD3.lockDuration() == 0): commit() stakes atomically (measured by balance delta), unlock() delivers the wrapped shares. A non-zero lock is hard-rejected at commit with DepositLockActive() (deposits have no recovery path).
  • Redeem — cooldown-gated (~30d): commit() calls sUSD3.startCooldown(); unlock() becomes available once sUSD3.maxRedeem(this) > 0 (cooldown matured and not blocked by unrealized losses / backing floor). Partial-fill aware — redeems as much as sUSD3 allows and returns to PROCESSING for the remainder.
  • Operator-abortable recovery: cancelRedeem() cancels the cooldown (PROCESSING → RECOVERING); recover() re-wraps the tracked pendingRedeemShares back to the receiver. This is the guaranteed escape for a stuck/backing-limited redeem, and clears any sub-wei floor-rounding residual. Only REDEEM orders recover.
  • Deposits floored at USD3.minDeposit() (BelowMinDeposit in create()); chained-conversion slippage guard; decimals sanity check on init.
  • totalAssets() reports wrapper-wide aggregate AUM (shared wrapper caveat, as with the other funds).

Files

  • src/funds/susd3/SUSD3Fund.sol, SUSD3FundFactory.sol (beacon proxy)
  • src/interfaces/funds/susd3/ISUSD3Fund.sol, src/interfaces/integrations/susd3/{ISUSD3,IUSD3}.sol
  • src/libs/funds/LibFundsErrors.sol — adds BelowMinDeposit(), DepositLockActive()
  • Tests + ERC4626 mocks under test/funds/susd3/ and test/mock/funds/susd3/
  • README: ### sUSD3 (Staked USD3) Integration + SUSD3FundFactory in the factory list

Testing

45 sUSD3 tests, all green — 27 unit + 9 factory + 3 fuzz (256 runs) + 2 invariants (256×64) + 4 mainnet-fork.

  • Mainnet-fork tests run against the real sUSD3/USD3 at block 25_440_000 (a recent block where the 30-day cooldown is active; the 24_570_780 block reused by the other funds' fork tests predates it).
  • The fork redeem test validates the real floor-rounding dust-stranding end-to-end: the redeem delivers ~all the USDC and the operator recovery path clears the sub-wei residual.
  • Full 455-test fund regression suite stays green (the LibFundsErrors edit is additive).
  • forge build --sizes: SUSD3Fund = 17,426 B (7.1 KB under the limit); forge fmt / forge lint clean on the new code.
forge test --match-path "test/funds/susd3/*"                                   # unit + fuzz + factory
ETH_RPC_URL=<rpc> FOUNDRY_PROFILE=ci   forge test --match-path "test/funds/susd3/*"   # + fork
ETH_RPC_URL=<rpc> FOUNDRY_PROFILE=full forge test --match-path "test/funds/susd3/*"   # + invariants

Notes for reviewers / deployment

  • Post-deploy step (as with the other funds): the wsUSD3 WrappedAsset owner must grant each new fund ISSUER_ROLE. Don't revoke it mid-order.
  • Design assumes sUSD3 lockDuration and USD3 minCommitmentTime stay 0 (a lock is hard-rejected at commit; a commitment change is a documented assumption).
  • The sUSD3/ reference protocol source (used only for context) is intentionally not included in this PR.

Wraps the sUSD3 subordinate tranche (staked USD3) behind the IFund state
machine. The fund's asset is USDC, but sUSD3 stakes USD3, so deposits convert
USDC -> USD3 -> sUSD3 and redemptions reverse it (double ERC-4626 conversion).

- Deposits are synchronous (sUSD3 lockDuration is 0); a non-zero lock is
  rejected at commit with DepositLockActive().
- Redemptions are cooldown-gated (~30d): commit starts the sUSD3 cooldown and
  unlock claims once sUSD3.maxRedeem() > 0, with partial-fill support.
- Operator-abortable recovery: cancelRedeem() cancels the cooldown
  (PROCESSING -> RECOVERING) and recover() re-wraps the tracked remainder back
  to the receiver -- the escape for a blocked/backing-limited redeem and any
  floor-rounding dust.
- One shared wsUSD3 WrappedAsset backs all instances; deploy multiple funds to
  run concurrent settlements.

Adds SUSD3Fund + SUSD3FundFactory (beacon proxy), ISUSD3Fund / ISUSD3 / IUSD3
interfaces, BelowMinDeposit + DepositLockActive errors, ERC4626 sUSD3/USD3
mocks, and unit/factory/fuzz/invariant/mainnet-fork tests. Documented in README.
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c8cfc2d0-36fa-44d6-b7a3-8f3abdfea355

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch susd3-fund

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@maximebrugel maximebrugel marked this pull request as draft July 2, 2026 14:01
The sUSD3 deposit lock (lockDuration) was active (30d) for essentially all of
sUSD3's history and was only swapped to the withdrawal cooldown ~3 weeks ago;
it is governance-controlled and could be re-enabled. Instead of rejecting a
deposit while a lock is active, the fund now stakes synchronously in commit()
and defers delivery of the wrapped shares until the per-account
sUSD3.lockedUntil elapses (state() gates DEPOSIT -> UNLOCKING on it). Gating on
lockedUntil (not lockDuration) keeps an in-flight deposit robust to mid-flight
config changes.

- Drop the DepositLockActive() commit guard and the now-unused error.
- Add ISUSD3.lockedUntil(address).
- Tests: mock-based async locked-deposit lifecycle, plus a fork test that
  re-enables the real lock via ProtocolConfig and verifies deferred delivery.
USD3 has no lock and its commitment-period transfer guard exempts staking into
sUSD3, so the received USD3 is always immediately stakeable; only sUSD3's lock
requires deferred delivery. Records the invariant where commit() stakes.
@maximebrugel maximebrugel marked this pull request as ready for review July 6, 2026 12:35
Comment thread src/funds/susd3/SUSD3Fund.sol
Comment thread src/funds/susd3/SUSD3Fund.sol Outdated
Comment thread src/funds/susd3/SUSD3Fund.sol
Comment thread src/funds/susd3/SUSD3Fund.sol Outdated
Mirror ParetoFund's resolve pattern: state() now gates DEPOSIT delivery on
`depositReceived >= (hasResolvedAmounts ? resolvedOutput : order.output)` (in
addition to the sUSD3 lock check), so a deposit that yields fewer sUSD3 shares
than requested (e.g. an adverse rate move between create and commit) stays in
PROCESSING. An OPERATOR_ROLE/owner `resolve(order, input, output)` lowers the
threshold to unstick it.

- Add hasResolvedAmounts/resolvedOutput storage (reset in create()).
- Add resolve() + OrderResolved event to ISUSD3Fund.
- Tests: stuck-deposit -> resolve -> unlock, not-processing revert, access control.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants