Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/contracts/SafetyNetViewer.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {ISafetyNetViewer} from '../interfaces/ISafetyNetViewer.sol';
import {ISafetyNet} from '../interfaces/ISafetyNet.sol';
import {SafetyNet} from '../contracts/SafetyNet.sol';

contract SafetyNetViewer is ISafetyNetViewer {
SafetyNet public immutable safetyNet;

Check warning on line 9 in src/contracts/SafetyNetViewer.sol

View workflow job for this annotation

GitHub Actions / Lint Commit Messages

Immutable variables name should be capitalized SNAKE_CASE

constructor(address _safetyNet) {
safetyNet = SafetyNet(_safetyNet);
}

function getMemberEpochStatus(uint256 _id, address _member) external view override returns (MemberEpochStatus memory status) {
uint256 epoch = safetyNet.getCurrentEpochIndex(_id);
status.currentEpoch = epoch;
status.hasDeposited = safetyNet.hasMemberDepositedInEpoch(_id, _member, epoch);
status.duesRemaining = safetyNet.duesRemainingThisEpoch(_id, _member);
status.smallWithdrawsUsed = safetyNet.smallWithdrawsCount(_id, epoch, _member);
status.withdrawableBalance = safetyNet.memberWithdrawableBalance(_id, _member);
}

function getPoolLiquidity(uint256 _id) external view override returns (PoolLiquidity memory pool) {
ISafetyNet.SafetyNet memory sn = safetyNet.getSafetyNet(_id);
pool.totalBalance = safetyNet.safetyNetBalance(_id);
pool.memberCount = sn.members.length;
uint256 epoch = safetyNet.getCurrentEpochIndex(_id);
uint256 active = 0;
for (uint256 i = 0; i < sn.members.length; i++) {
if (safetyNet.hasMemberDepositedInEpoch(_id, sn.members[i], epoch)) {
active++;
}
}
pool.activeMemberCount = active;
}

function getMemberDashboard(uint256 _id, address _member) external view override returns (MemberDashboard memory dashboard) {
// Epoch status
uint256 epoch = safetyNet.getCurrentEpochIndex(_id);
dashboard.epochStatus.currentEpoch = epoch;
dashboard.epochStatus.hasDeposited = safetyNet.hasMemberDepositedInEpoch(_id, _member, epoch);
dashboard.epochStatus.duesRemaining = safetyNet.duesRemainingThisEpoch(_id, _member);
dashboard.epochStatus.smallWithdrawsUsed = safetyNet.smallWithdrawsCount(_id, epoch, _member);
dashboard.epochStatus.withdrawableBalance = safetyNet.memberWithdrawableBalance(_id, _member);

// Pool liquidity
ISafetyNet.SafetyNet memory sn = safetyNet.getSafetyNet(_id);
dashboard.poolLiquidity.totalBalance = safetyNet.safetyNetBalance(_id);
dashboard.poolLiquidity.memberCount = sn.members.length;
uint256 active = 0;
for (uint256 i = 0; i < sn.members.length; i++) {
if (safetyNet.hasMemberDepositedInEpoch(_id, sn.members[i], epoch)) {
active++;
}
}
dashboard.poolLiquidity.activeMemberCount = active;

// Member's safety nets
dashboard.memberSafetyNetIds = safetyNet.getMemberSafetyNets(_member);
}
}
30 changes: 30 additions & 0 deletions src/interfaces/ISafetyNetViewer.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {ISafetyNet} from './ISafetyNet.sol';

Check warning on line 4 in src/interfaces/ISafetyNetViewer.sol

View workflow job for this annotation

GitHub Actions / Lint Commit Messages

Variable "ISafetyNet" is unused

interface ISafetyNetViewer {
struct MemberEpochStatus {
bool hasDeposited;
uint256 duesRemaining;
uint256 smallWithdrawsUsed;
uint256 withdrawableBalance;
uint256 currentEpoch;
}

struct PoolLiquidity {
uint256 totalBalance;
uint256 memberCount;
uint256 activeMemberCount;
}

struct MemberDashboard {
MemberEpochStatus epochStatus;
PoolLiquidity poolLiquidity;
uint256[] memberSafetyNetIds;
}

function getMemberEpochStatus(uint256 safetyNetId, address member) external view returns (MemberEpochStatus memory);
function getPoolLiquidity(uint256 safetyNetId) external view returns (PoolLiquidity memory);
function getMemberDashboard(uint256 safetyNetId, address member) external view returns (MemberDashboard memory);
}
227 changes: 227 additions & 0 deletions test/unit/SafetyNetViewer.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;

import {ProxyAdmin} from '@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol';
import {TransparentUpgradeableProxy} from '@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol';
import {Test} from 'forge-std/Test.sol';

import {SafetyNet} from 'src/contracts/SafetyNet.sol';
import {SafetyNetViewer} from 'src/contracts/SafetyNetViewer.sol';
import {ISafetyNet} from 'src/interfaces/ISafetyNet.sol';
import {ISafetyNetViewer} from 'src/interfaces/ISafetyNetViewer.sol';
import {MockERC20} from 'test/mocks/MockERC20.sol';

contract SafetyNetViewerTest is Test {
SafetyNet internal _sn;
SafetyNetViewer internal _viewer;
MockERC20 internal _token;

address internal _owner;
address internal _alice = makeAddr('alice');
address internal _bob = makeAddr('bob');

uint256 internal _snId;

function setUp() public {
(_owner,) = makeAddrAndKey('owner');

// Deploy upgradeable proxy
address impl = address(new SafetyNet());
address admin = address(new ProxyAdmin(_owner));
address proxy = address(
new TransparentUpgradeableProxy(impl, admin, abi.encodeWithSelector(SafetyNet.initialize.selector, _owner))
);
_sn = SafetyNet(proxy);

// Deploy viewer pointing at the proxy
_viewer = new SafetyNetViewer(proxy);

// Setup token
_token = new MockERC20('Mock', 'MOCK');
vm.prank(_owner);
_sn.setTokenAllowed(address(_token), true);

// Fund and approve
_token.mint(_alice, 1_000_000 ether);
_token.mint(_bob, 1_000_000 ether);
vm.prank(_alice);
_token.approve(address(_sn), type(uint256).max);
vm.prank(_bob);
_token.approve(address(_sn), type(uint256).max);

// Create a safety net with alice and bob
address[] memory members = new address[](2);
members[0] = _alice;
members[1] = _bob;
ISafetyNet.SafetyNet memory snConfig = ISafetyNet.SafetyNet({
id: 0,
owner: _owner,
minimumMembers: 2,
maximumMembers: 5,
consensusThreshold: 60,
safetyNetStart: block.timestamp,
token: address(_token),
members: members,
initialDeposit: 100 ether,
fixedDeposit: 10 ether,
redeemRatio: 1,
autoThreshold: 50 ether,
contestWindow: 3 days,
votingWindow: 7 days,
epochDuration: 30 days,
smallWithdrawsLimit: 3
});
_snId = _sn.create(snConfig);
}

// ---------- getMemberEpochStatus ----------

function test_GetMemberEpochStatus_BeforeDeposit() external view {
ISafetyNetViewer.MemberEpochStatus memory status = _viewer.getMemberEpochStatus(_snId, _alice);

assertEq(status.currentEpoch, 0, 'epoch should be 0');
assertFalse(status.hasDeposited, 'alice should not have deposited yet');
assertGt(status.duesRemaining, 0, 'dues remaining should be > 0 before deposit');
assertEq(status.smallWithdrawsUsed, 0, 'no small withdraws yet');
assertEq(status.withdrawableBalance, 0, 'no withdrawable balance yet');
}

function test_GetMemberEpochStatus_AfterInitialDeposit() external {
// Alice makes the initial deposit
vm.prank(_alice);
_sn.deposit(_snId, 100 ether);

ISafetyNetViewer.MemberEpochStatus memory status = _viewer.getMemberEpochStatus(_snId, _alice);

assertEq(status.currentEpoch, 0, 'epoch should be 0');
assertTrue(status.hasDeposited, 'alice should have deposited');
assertEq(status.duesRemaining, 0, 'no more dues after full initial deposit');
}

function test_GetMemberEpochStatus_PartialDeposit() external {
// Pay initial deposits for both members to complete epoch 0
vm.prank(_alice);
_sn.deposit(_snId, 100 ether);
vm.prank(_bob);
_sn.deposit(_snId, 100 ether);

// Advance to epoch 1 where fixedDeposit (10 ether) applies
ISafetyNet.SafetyNet memory snConfig = _sn.getSafetyNet(_snId);
vm.warp(snConfig.safetyNetStart + snConfig.epochDuration);

// Alice makes a partial deposit (5 out of 10 ether)
vm.prank(_alice);
_sn.deposit(_snId, 5 ether);

ISafetyNetViewer.MemberEpochStatus memory status = _viewer.getMemberEpochStatus(_snId, _alice);

assertFalse(status.hasDeposited, 'partial deposit is not a full deposit');
assertGt(status.duesRemaining, 0, 'still has dues remaining');
}

// ---------- getPoolLiquidity ----------

function test_GetPoolLiquidity_BeforeDeposits() external view {
ISafetyNetViewer.PoolLiquidity memory pool = _viewer.getPoolLiquidity(_snId);

assertEq(pool.totalBalance, 0, 'no balance yet');
assertEq(pool.memberCount, 2, 'alice and bob are members');
assertEq(pool.activeMemberCount, 0, 'no active members yet (no deposits)');
}

function test_GetPoolLiquidity_AfterOneDeposit() external {
vm.prank(_alice);
_sn.deposit(_snId, 100 ether);

ISafetyNetViewer.PoolLiquidity memory pool = _viewer.getPoolLiquidity(_snId);

assertEq(pool.totalBalance, 100 ether, 'total should be 100 ether');
assertEq(pool.memberCount, 2, 'still 2 members');
assertEq(pool.activeMemberCount, 1, 'only alice is active');
}

function test_GetPoolLiquidity_AfterBothDeposit() external {
vm.prank(_alice);
_sn.deposit(_snId, 100 ether);
vm.prank(_bob);
_sn.deposit(_snId, 100 ether);

ISafetyNetViewer.PoolLiquidity memory pool = _viewer.getPoolLiquidity(_snId);

assertEq(pool.totalBalance, 200 ether, 'total should be 200 ether');
assertEq(pool.memberCount, 2, 'still 2 members');
assertEq(pool.activeMemberCount, 2, 'both members are active');
}

// ---------- getMemberDashboard ----------

function test_GetMemberDashboard_BeforeDeposit() external view {
ISafetyNetViewer.MemberDashboard memory dashboard = _viewer.getMemberDashboard(_snId, _alice);

// Epoch status checks
assertEq(dashboard.epochStatus.currentEpoch, 0, 'epoch should be 0');
assertFalse(dashboard.epochStatus.hasDeposited, 'alice has not deposited');
assertGt(dashboard.epochStatus.duesRemaining, 0, 'dues remaining > 0');
assertEq(dashboard.epochStatus.smallWithdrawsUsed, 0, 'no small withdraws');
assertEq(dashboard.epochStatus.withdrawableBalance, 0, 'no withdrawable balance');

// Pool liquidity checks
assertEq(dashboard.poolLiquidity.totalBalance, 0, 'no total balance');
assertEq(dashboard.poolLiquidity.memberCount, 2, '2 members');
assertEq(dashboard.poolLiquidity.activeMemberCount, 0, 'no active members');

// Safety net IDs for alice
assertEq(dashboard.memberSafetyNetIds.length, 1, 'alice has 1 safety net');
assertEq(dashboard.memberSafetyNetIds[0], _snId, 'safety net id matches');
}

function test_GetMemberDashboard_AfterBothDeposit() external {
vm.prank(_alice);
_sn.deposit(_snId, 100 ether);
vm.prank(_bob);
_sn.deposit(_snId, 100 ether);

ISafetyNetViewer.MemberDashboard memory dashboard = _viewer.getMemberDashboard(_snId, _alice);

assertTrue(dashboard.epochStatus.hasDeposited, 'alice has deposited');
assertEq(dashboard.epochStatus.duesRemaining, 0, 'no dues remaining');
assertEq(dashboard.poolLiquidity.totalBalance, 200 ether, '200 ether total');
assertEq(dashboard.poolLiquidity.activeMemberCount, 2, 'both active');
assertEq(dashboard.memberSafetyNetIds.length, 1, 'alice in 1 safety net');
}

function test_GetMemberDashboard_MemberInMultipleSafetyNets() external {
// Create a second safety net for alice
vm.prank(_owner);
_sn.setTokenAllowed(address(_token), true);

address[] memory members = new address[](2);
members[0] = _alice;
members[1] = _bob;
ISafetyNet.SafetyNet memory snConfig2 = ISafetyNet.SafetyNet({
id: 0,
owner: _owner,
minimumMembers: 2,
maximumMembers: 5,
consensusThreshold: 60,
safetyNetStart: block.timestamp,
token: address(_token),
members: members,
initialDeposit: 100 ether,
fixedDeposit: 10 ether,
redeemRatio: 1,
autoThreshold: 50 ether,
contestWindow: 3 days,
votingWindow: 7 days,
epochDuration: 30 days,
smallWithdrawsLimit: 3
});
uint256 snId2 = _sn.create(snConfig2);

ISafetyNetViewer.MemberDashboard memory dashboard = _viewer.getMemberDashboard(_snId, _alice);

assertEq(dashboard.memberSafetyNetIds.length, 2, 'alice is in 2 safety nets');
assertEq(dashboard.memberSafetyNetIds[0], _snId, 'first safety net');
assertEq(dashboard.memberSafetyNetIds[1], snId2, 'second safety net');
}
}
Loading