Skip to content

Commit 1987a65

Browse files
committed
added spark yield backend
1 parent 82bc629 commit 1987a65

File tree

2 files changed

+315
-0
lines changed

2 files changed

+315
-0
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.23;
3+
4+
import { IYieldBackend } from "../interfaces/superfluid/IYieldBackend.sol";
5+
import { IERC20, ISuperToken } from "../interfaces/superfluid/ISuperfluid.sol";
6+
import { IERC4626 } from "@openzeppelin-v5/contracts/interfaces/IERC4626.sol";
7+
8+
// Note: Spark Vaults on Base/Mainnet are ERC4626 compliant.
9+
10+
contract SparkYieldBackend is IYieldBackend {
11+
IERC20 public immutable ASSET_TOKEN;
12+
IERC4626 public immutable VAULT;
13+
address public immutable SURPLUS_RECEIVER;
14+
15+
constructor(IERC4626 vault, address surplusReceiver) {
16+
VAULT = vault;
17+
ASSET_TOKEN = IERC20(vault.asset());
18+
SURPLUS_RECEIVER = surplusReceiver;
19+
}
20+
21+
function enable() external {
22+
ASSET_TOKEN.approve(address(VAULT), type(uint256).max);
23+
}
24+
25+
function disable() external {
26+
ASSET_TOKEN.approve(address(VAULT), 0);
27+
}
28+
29+
function deposit(uint256 amount) external {
30+
require(amount > 0, "amount must be greater than 0");
31+
VAULT.deposit(amount, address(this));
32+
}
33+
34+
function depositMax() external {
35+
uint256 amount = ASSET_TOKEN.balanceOf(address(this));
36+
if (amount > 0) {
37+
VAULT.deposit(amount, address(this));
38+
}
39+
}
40+
41+
function withdraw(uint256 amount) external {
42+
VAULT.withdraw(amount, address(this), address(this));
43+
}
44+
45+
function withdrawMax() external {
46+
uint256 balance = VAULT.maxWithdraw(address(this));
47+
if (balance > 0) {
48+
VAULT.withdraw(balance, address(this), address(this));
49+
}
50+
}
51+
52+
function withdrawSurplus(uint256 totalSupply) external {
53+
(uint256 normalizedTotalSupply, ) = ISuperToken(address(this))
54+
.toUnderlyingAmount(totalSupply);
55+
56+
uint256 vaultAssets = VAULT.convertToAssets(
57+
VAULT.balanceOf(address(this))
58+
);
59+
60+
uint256 surplusAmount = vaultAssets + ASSET_TOKEN.balanceOf(address(this)) - normalizedTotalSupply;
61+
VAULT.withdraw(surplusAmount, SURPLUS_RECEIVER, address(this));
62+
}
63+
64+
function getManagedAmount() external view returns (uint256) {
65+
return VAULT.convertToAssets(VAULT.balanceOf(address(this)));
66+
}
67+
}
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
// SPDX-License-Identifier: AGPLv3
2+
pragma solidity ^0.8.23;
3+
4+
import { Test } from "forge-std/Test.sol";
5+
import { console } from "forge-std/console.sol";
6+
import {
7+
SparkYieldBackend
8+
} from "../../../contracts/superfluid/SparkYieldBackend.sol";
9+
import {
10+
IERC20,
11+
ISuperfluid
12+
} from "../../../contracts/interfaces/superfluid/ISuperfluid.sol";
13+
import { SuperToken } from "../../../contracts/superfluid/SuperToken.sol";
14+
import { IERC4626 } from "@openzeppelin-v5/contracts/interfaces/IERC4626.sol";
15+
16+
/**
17+
* @title SparkYieldBackendForkTest
18+
* @notice Fork test for testing yield-related features with SparkYieldBackend on Base
19+
* @author Superfluid
20+
*/
21+
contract SparkYieldBackendForkTest is Test {
22+
address internal constant ALICE = address(0x420);
23+
address internal constant ADMIN = address(0xAAA);
24+
address internal constant SURPLUS_RECEIVER = 0xac808840f02c47C05507f48165d2222FF28EF4e1; // dao.superfluid.eth
25+
26+
// Base network constants
27+
uint256 internal constant CHAIN_ID = 8453;
28+
string internal constant RPC_URL = "https://mainnet.base.org";
29+
30+
// Spark USDC Vault on Base (sUSDC)
31+
address internal constant SPARK_VAULT = 0x3128a0F7f0ea68E7B7c9B00AFa7E41045828e858;
32+
33+
// Common tokens on Base
34+
address internal constant USDCx = 0xD04383398dD2426297da660F9CCA3d439AF9ce1b;
35+
address internal constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913;
36+
37+
38+
SuperToken internal superToken;
39+
SparkYieldBackend internal sparkBackend;
40+
IERC20 internal underlyingToken;
41+
IERC4626 internal vault;
42+
43+
function setUp() public {
44+
vm.createSelectFork(RPC_URL);
45+
assertEq(block.chainid, CHAIN_ID, "Chainid mismatch");
46+
47+
vault = IERC4626(SPARK_VAULT);
48+
underlyingToken = IERC20(USDC);
49+
50+
superToken = SuperToken(USDCx);
51+
52+
sparkBackend = new SparkYieldBackend(vault, SURPLUS_RECEIVER);
53+
54+
assertEq(
55+
address(sparkBackend.ASSET_TOKEN()),
56+
USDC,
57+
"Asset token mismatch"
58+
);
59+
assertEq(address(sparkBackend.VAULT()), SPARK_VAULT, "Vault mismatch");
60+
61+
// upgrade SuperToken to new logic (mocking upgrade to enable features if needed,
62+
// essentially ensuring we have a fresh state or compatible logic)
63+
// Note: SuperToken on Base might already be up to date, but we re-deploy logic for safety in test
64+
SuperToken newSuperTokenLogic = new SuperToken(
65+
ISuperfluid(superToken.getHost()),
66+
superToken.POOL_ADMIN_NFT()
67+
);
68+
vm.startPrank(address(superToken.getHost()));
69+
superToken.updateCode(address(newSuperTokenLogic));
70+
vm.stopPrank();
71+
72+
// designate an admin for the SuperToken
73+
vm.startPrank(address(superToken.getHost()));
74+
superToken.changeAdmin(ADMIN);
75+
vm.stopPrank();
76+
77+
// provide ALICE with underlying and let her approve for upgrade
78+
deal(USDC, ALICE, type(uint128).max);
79+
vm.startPrank(ALICE);
80+
IERC20(USDC).approve(address(superToken), type(uint256).max);
81+
}
82+
83+
function _enableYieldBackend() public {
84+
vm.startPrank(ADMIN);
85+
superToken.enableYieldBackend(sparkBackend);
86+
vm.stopPrank();
87+
}
88+
89+
function _verifyInvariants() internal view {
90+
// underlyingBalance + vaultAssets >= superToken.supply()
91+
uint256 underlyingBalance = IERC20(USDC).balanceOf(address(superToken));
92+
// vault balance is in shares, need to convert to assets
93+
uint256 vaultAssets = vault.convertToAssets(
94+
vault.balanceOf(address(superToken))
95+
);
96+
97+
(uint256 superTokenNormalizedSupply, ) = superToken.toUnderlyingAmount(
98+
superToken.totalSupply()
99+
);
100+
101+
// We use approx because of potential rounding/yield accruing differently per block
102+
// But assets should be >= supply
103+
assertGe(
104+
underlyingBalance + vaultAssets,
105+
superTokenNormalizedSupply,
106+
"invariant failed: underlying + vaultAssets insufficient"
107+
);
108+
}
109+
110+
function testSparkBackendDeployment() public view {
111+
assertEq(
112+
address(sparkBackend.ASSET_TOKEN()),
113+
USDC,
114+
"Asset token should be USDC"
115+
);
116+
assertEq(
117+
address(sparkBackend.VAULT()),
118+
SPARK_VAULT,
119+
"Vault address should match"
120+
);
121+
}
122+
123+
function testEnableYieldBackend() public {
124+
_enableYieldBackend();
125+
126+
assertEq(
127+
address(superToken.getYieldBackend()),
128+
address(sparkBackend),
129+
"Yield backend mismatch"
130+
);
131+
132+
// For new deposits, we need to upgrade
133+
uint256 amount = 100 * 1e18;
134+
vm.startPrank(ALICE);
135+
superToken.upgrade(amount);
136+
vm.stopPrank();
137+
138+
// the SuperToken should now have a zero USDC balance (all deposited)
139+
assertEq(
140+
IERC20(USDC).balanceOf(address(superToken)),
141+
0,
142+
"USDC balance should be zero"
143+
);
144+
145+
// And non-zero vault balance
146+
assertGt(
147+
vault.balanceOf(address(superToken)),
148+
0,
149+
"Vault share balance should be non-zero"
150+
);
151+
152+
_verifyInvariants();
153+
}
154+
155+
function testDisableYieldBackend() public {
156+
_enableYieldBackend();
157+
158+
// Deposit some funds first so we have something to withdraw
159+
vm.startPrank(ALICE);
160+
superToken.upgrade(100 * 1e18);
161+
vm.stopPrank();
162+
163+
vm.startPrank(ADMIN);
164+
superToken.disableYieldBackend();
165+
vm.stopPrank();
166+
assertEq(
167+
address(superToken.getYieldBackend()),
168+
address(0),
169+
"Yield backend mismatch"
170+
);
171+
172+
// the SuperToken should now have a non-zero USDC balance and a zero vault balance
173+
assertGt(
174+
IERC20(USDC).balanceOf(address(superToken)),
175+
0,
176+
"USDC balance should be non-zero"
177+
);
178+
assertEq(
179+
vault.balanceOf(address(superToken)),
180+
0,
181+
"Vault balance should be zero"
182+
);
183+
184+
_verifyInvariants();
185+
}
186+
187+
function testUpgradeDowngrade() public {
188+
_enableYieldBackend();
189+
190+
uint256 vaultSharesBefore = vault.balanceOf(address(superToken));
191+
uint256 amount = 100 * 1e18; // 100 USDCx
192+
193+
vm.startPrank(ALICE);
194+
superToken.upgrade(amount);
195+
vm.stopPrank();
196+
197+
uint256 vaultSharesAfter = vault.balanceOf(address(superToken));
198+
199+
assertGt(
200+
vaultSharesAfter,
201+
vaultSharesBefore,
202+
"Vault shares should increase"
203+
);
204+
205+
// downgrade
206+
vm.startPrank(ALICE);
207+
superToken.downgrade(amount);
208+
vm.stopPrank();
209+
210+
uint256 vaultSharesFinal = vault.balanceOf(address(superToken));
211+
assertLt(
212+
vaultSharesFinal,
213+
vaultSharesAfter,
214+
"Vault shares should decrease"
215+
);
216+
217+
_verifyInvariants();
218+
}
219+
220+
function testWithdrawSurplusFromYieldBackend() public {
221+
// simulate the SuperToken having a surplus of underlying from the start
222+
uint256 surplusAmount = 100 * 1e6; // 100 USDC
223+
deal(USDC, address(superToken), surplusAmount);
224+
225+
_enableYieldBackend();
226+
227+
uint256 receiverBalanceBefore = IERC20(USDC).balanceOf(
228+
SURPLUS_RECEIVER
229+
);
230+
231+
vm.startPrank(ADMIN);
232+
superToken.withdrawSurplusFromYieldBackend();
233+
vm.stopPrank();
234+
235+
uint256 receiverBalanceAfter = IERC20(USDC).balanceOf(SURPLUS_RECEIVER);
236+
237+
console.log("Receiver balance before", receiverBalanceBefore);
238+
console.log("Receiver balance after", receiverBalanceAfter);
239+
console.log("Diff", receiverBalanceAfter - receiverBalanceBefore);
240+
241+
assertGt(
242+
receiverBalanceAfter,
243+
receiverBalanceBefore,
244+
"Surplus should be withdrawn to receiver"
245+
);
246+
_verifyInvariants();
247+
}
248+
}

0 commit comments

Comments
 (0)