From 43e6e246ab5cad260bcaf3ee50eaae59ca3e66df Mon Sep 17 00:00:00 2001 From: Ian Lucas Date: Tue, 3 Dec 2024 17:50:01 -0500 Subject: [PATCH] CBL TGE Staking campaign (#175) CBL TGE Staking campaign --- .github/workflows/ci-dev-sdk.yml | 6 + package.json | 1 + .../resource/testnetBaseSepolia.toml | 50 ++++ packages/contracts/script/DeployMocks.s.sol | 6 +- .../script/DeployStakingVaults.s.sol | 141 +++++++++++ .../CredbullFixedYieldVaultStakingTest.t.sol | 81 +++++++ .../src/CredbullFixedYieldVaultTest.t.sol | 58 ++++- packages/sdk/.gitignore | 1 + packages/sdk/package.json | 2 + .../test/resource/TEST-vault-deposit-0.json | 3 + .../test/resource/TEST-vault-deposit-3.json | 25 ++ packages/sdk/test/resource/test-local.toml | 11 +- packages/sdk/test/resource/test-mainnet.toml | 18 ++ packages/sdk/test/resource/test-testnet.toml | 54 +++++ packages/sdk/test/src/liquid-vault.spec.ts | 8 +- .../sdk/test/src/staking/vault-deposit-app.ts | 76 ++++++ .../src/staking/vault-deposit-parser.spec.ts | 21 ++ .../test/src/staking/vault-deposit-parser.ts | 30 +++ .../test/src/staking/vault-deposit.spec.ts | 82 +++++++ .../sdk/test/src/staking/vault-deposit.ts | 148 ++++++++++++ .../test/src/staking/vault-utility.spec.ts | 219 ++++++++++++++++++ packages/sdk/test/src/utils/config.ts | 6 +- packages/sdk/test/src/utils/decoder.ts | 30 +++ packages/sdk/test/src/utils/logger.spec.ts | 53 +++++ packages/sdk/test/src/utils/logger.ts | 64 +++++ .../sdk/test/src/utils/test-signer.spec.ts | 19 +- packages/sdk/test/src/utils/test-signer.ts | 25 +- yarn.lock | 29 +++ 28 files changed, 1236 insertions(+), 31 deletions(-) create mode 100644 packages/contracts/resource/testnetBaseSepolia.toml create mode 100644 packages/contracts/script/DeployStakingVaults.s.sol create mode 100644 packages/contracts/test/src/CredbullFixedYieldVaultStakingTest.t.sol create mode 100644 packages/sdk/test/resource/TEST-vault-deposit-0.json create mode 100644 packages/sdk/test/resource/TEST-vault-deposit-3.json create mode 100644 packages/sdk/test/resource/test-mainnet.toml create mode 100644 packages/sdk/test/resource/test-testnet.toml create mode 100644 packages/sdk/test/src/staking/vault-deposit-app.ts create mode 100644 packages/sdk/test/src/staking/vault-deposit-parser.spec.ts create mode 100644 packages/sdk/test/src/staking/vault-deposit-parser.ts create mode 100644 packages/sdk/test/src/staking/vault-deposit.spec.ts create mode 100644 packages/sdk/test/src/staking/vault-deposit.ts create mode 100644 packages/sdk/test/src/staking/vault-utility.spec.ts create mode 100644 packages/sdk/test/src/utils/decoder.ts create mode 100644 packages/sdk/test/src/utils/logger.spec.ts create mode 100644 packages/sdk/test/src/utils/logger.ts diff --git a/.github/workflows/ci-dev-sdk.yml b/.github/workflows/ci-dev-sdk.yml index 4c5907c43..b67f36872 100644 --- a/.github/workflows/ci-dev-sdk.yml +++ b/.github/workflows/ci-dev-sdk.yml @@ -66,6 +66,12 @@ jobs: - name: Run Docker Compose with Background Services run: docker compose --env-file packages/api/.env up --detach --build --wait + - name: Deploy Credbull Staking Vault + run: yarn deploy + working-directory: packages/contracts + env: + DEPLOY_SCRIPT: "script/DeployStakingVaults.s.sol:DeployStakingVaults" + - name: Deploy Credbull Contracts run: yarn deploy working-directory: packages/contracts diff --git a/package.json b/package.json index 21287611e..f83652db8 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "postinstall": "husky install" }, "devDependencies": { + "@types/winston": "^2.4.4", "husky": "^9.0.11", "lint-staged": "^15.2.7", "turbo": "^2.0.6" diff --git a/packages/contracts/resource/testnetBaseSepolia.toml b/packages/contracts/resource/testnetBaseSepolia.toml new file mode 100644 index 000000000..52e2379ac --- /dev/null +++ b/packages/contracts/resource/testnetBaseSepolia.toml @@ -0,0 +1,50 @@ +## +# The Application Configuration for the TestNet Environment. +## + +[evm] +# blockchain id, e.g. baseSepolia=84532, arbSepolia=421614 +chain_id = 84532 +deploy_mocks = false + +[evm.address] +# credbull-devops wallets. wallet numbers are 1-based (as opposed to 0-based in anvil) +# devops admin/owner (wallet 1) - public address, okay to share +owner = "0xD79Be36f61fce3B8EF2FBF22b13B2b9a68eE15A2" +# devops operator (wallet 2) - public address, okay to share +operator = "0xaD3C004eE1f942BFDA2DA0D2DAaC94d6aC012F75" +# devops custodian (wallet 3) - public address, okay to share +custodian = "0x8561845F6a9511cD8e2daCae77A961e718A77cF6" +# devops upgrader (wallet 4) - public address, okay to share +upgrader = "0xaD3C004eE1f942BFDA2DA0D2DAaC94d6aC012F75" +# devops asset manager (wallet 7) - public address, okay to share +asset_manager = "0xd097E901FB9B75C2d2f97E142d73fA79C31FcAb3" +# CBL token address - Base Sepolia +cbl_token="0x2064334877Fac12f353b8FB42440805709eC267A" +# USDC token address - Base Sepolia +usdc_token="0x036CbD53842c5426634e7929541eC2318f3dCF7e" + +[evm.contracts.liquid_continuous_multi_token_vault] +# rate in basis points, e.g. 10% = 1000 bps +full_rate_bps = 10_00 +# rate in basis points, e.g. 5.5% = 550 bps +reduced_rate_bps = 5_50 + +[evm.contracts.upside_vault] +# 2 decimal place percentage (meaining value divided by 100) as integer. +collateral_percentage = 200 + +[evm.contracts.cbl] +# CBL token params +# devops admin/owner (wallet 1) - public address, okay to share +owner = "0xD79Be36f61fce3B8EF2FBF22b13B2b9a68eE15A2" +# devops operator (wallet 2) - public address, okay to share +minter = "0xaD3C004eE1f942BFDA2DA0D2DAaC94d6aC012F75" +# CBL token params +max_supply = 10_000_000 # 10 million in wei + +[services.supabase] +url = "https://kyvvhlnmoqibdihqrlmc.supabase.co" + +# Save the contract deployment details to the database. +update_contract_addresses = true diff --git a/packages/contracts/script/DeployMocks.s.sol b/packages/contracts/script/DeployMocks.s.sol index 5eef611c8..0216298e0 100755 --- a/packages/contracts/script/DeployMocks.s.sol +++ b/packages/contracts/script/DeployMocks.s.sol @@ -39,14 +39,14 @@ contract DeployMocks is Script { if (isTestMode || deployChecker.isDeployRequired("SimpleToken")) { testToken = new SimpleToken(owner, totalSupply); - console2.log("!!!!! Deploying SimpleToken !!!!!"); + console2.log(string.concat("!!!!! Deploying SimpleToken [", vm.toString(address(testToken)), "] !!!!!")); } else { testToken = SimpleToken(deployChecker.getContractAddress("SimpleToken")); } if (isTestMode || deployChecker.isDeployRequired("SimpleUSDC")) { testStablecoin = new SimpleUSDC(owner, totalSupply); - console2.log("!!!!! Deploying SimpleToken !!!!!"); + console2.log(string.concat("!!!!! Deploying SimpleUSDC [", vm.toString(address(testStablecoin)), "] !!!!!")); } else { testStablecoin = SimpleUSDC(deployChecker.getContractAddress("SimpleUSDC")); } @@ -59,7 +59,7 @@ contract DeployMocks is Script { custodian: custodian }); testVault = new SimpleVault(params); - console2.log("!!!!! Deploying Simple Vault !!!!!"); + console2.log(string.concat("!!!!! Deploying Simple Vault [", vm.toString(address(testVault)), "] !!!!!")); } vm.stopBroadcast(); diff --git a/packages/contracts/script/DeployStakingVaults.s.sol b/packages/contracts/script/DeployStakingVaults.s.sol new file mode 100644 index 000000000..4ea4c2f1e --- /dev/null +++ b/packages/contracts/script/DeployStakingVaults.s.sol @@ -0,0 +1,141 @@ +//SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import { Script } from "forge-std/Script.sol"; + +import { HelperConfig, NetworkConfig } from "@script/HelperConfig.s.sol"; + +import { CredbullFixedYieldVaultFactory } from "@credbull/CredbullFixedYieldVaultFactory.sol"; +import { CredbullFixedYieldVault } from "@credbull/CredbullFixedYieldVault.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { Vault } from "@credbull/vault/Vault.sol"; +import { MaturityVault } from "@credbull/vault/MaturityVault.sol"; +import { FixedYieldVault } from "@credbull/vault/FixedYieldVault.sol"; +import { WindowPlugin } from "@credbull/plugin/WindowPlugin.sol"; +import { WhiteListPlugin } from "@credbull/plugin/WhiteListPlugin.sol"; +import { MaxCapPlugin } from "@credbull/plugin/MaxCapPlugin.sol"; + +import { console2 } from "forge-std/console2.sol"; + +contract DeployStakingVaults is Script { + bool private isTestMode; + + function runTest() + public + returns ( + CredbullFixedYieldVaultFactory factory, + CredbullFixedYieldVault stakingVault50APY_, + CredbullFixedYieldVault stakingVault0APY_, + HelperConfig helperConfig + ) + { + isTestMode = true; + return run(); + } + + function run() + public + returns ( + CredbullFixedYieldVaultFactory factory, + CredbullFixedYieldVault stakingVault50APY_, + CredbullFixedYieldVault stakingVault0APY_, + HelperConfig helperConfig + ) + { + helperConfig = new HelperConfig(isTestMode); + + vm.startBroadcast(); + + CredbullFixedYieldVault stakingVault50APY = new CredbullFixedYieldVault( + createStakingVaultParams(helperConfig, "inCredbull Earn CBL Staking Challenge", "iceCBLsc", 50) + ); + console2.log( + string.concat( + "!!!!! Deploying CredbullFixedYieldVault 50APY [", vm.toString(address(stakingVault50APY)), "] !!!!!" + ) + ); + + CredbullFixedYieldVault stakingVault0APY = new CredbullFixedYieldVault( + createStakingVaultParams(helperConfig, "inCredbull Earn CBL Booster Vault", "iceCBLBooster", 0) + ); + console2.log( + string.concat( + "!!!!! Deploying CredbullFixedYieldVault OAPY [", vm.toString(address(stakingVault0APY)), "] !!!!!" + ) + ); + + vm.stopBroadcast(); + + return (factory, stakingVault50APY, stakingVault0APY, helperConfig); + } + + function createStakingVaultParams( + HelperConfig helperConfig, + string memory shareName, + string memory shareSymbol, + uint256 _yieldPercentage + ) internal view returns (CredbullFixedYieldVault.FixedYieldVaultParams memory) { + NetworkConfig memory config = helperConfig.getNetworkConfig(); + Vault.VaultParams memory _vaultParams = Vault.VaultParams({ + asset: IERC20(config.cblToken), + shareName: shareName, + shareSymbol: shareSymbol, + custodian: config.factoryParams.custodian + }); + + MaturityVault.MaturityVaultParams memory _maturityVaultParams = + MaturityVault.MaturityVaultParams({ vault: _vaultParams }); + + FixedYieldVault.ContractRoles memory _contractRoles = FixedYieldVault.ContractRoles({ + owner: config.factoryParams.owner, + operator: config.factoryParams.operator, + custodian: config.factoryParams.custodian + }); + + uint256 depositStart = 1730973600; // Deposit Start: Nov 7 10 AM UTC - okay + WindowPlugin.Window memory _depositWindow = + WindowPlugin.Window({ opensAt: depositStart, closesAt: depositStart + 14 days - 1 }); + + uint256 redeemStart = _depositWindow.closesAt + 30 days + 1; // Withdraw Start: Deposit End + 30 days + WindowPlugin.Window memory _redemptionWindow = + WindowPlugin.Window({ opensAt: (redeemStart), closesAt: (redeemStart + 30 days - 1) }); + + _logWindowTimestamps(_depositWindow, _redemptionWindow); + + WindowPlugin.WindowPluginParams memory _windowPluginParams = + WindowPlugin.WindowPluginParams({ depositWindow: _depositWindow, redemptionWindow: _redemptionWindow }); + + WhiteListPlugin.WhiteListPluginParams memory _whiteListPluginParams = WhiteListPlugin.WhiteListPluginParams({ + whiteListProvider: config.factoryParams.owner, // using owner as the whitelist provider + depositThresholdForWhiteListing: type(uint256).max // logically disable the whitelist + }); + + uint256 maxCap = 10_000_000 ether; + MaxCapPlugin.MaxCapPluginParams memory _maxCapPluginParams = MaxCapPlugin.MaxCapPluginParams({ maxCap: maxCap }); // Max cap not necessary for staking vaults + + return FixedYieldVault.FixedYieldVaultParams({ + maturityVault: _maturityVaultParams, + roles: _contractRoles, + windowPlugin: _windowPluginParams, + whiteListPlugin: _whiteListPluginParams, + maxCapPlugin: _maxCapPluginParams, + promisedYield: _yieldPercentage + }); + } + + //@dev - see https://www.epochconverter.com/batch#results + function _logWindowTimestamps(WindowPlugin.Window memory depositWindow, WindowPlugin.Window memory redemptionWindow) + internal + pure + { + console2.log("==============================================================="); + console2.log("Vault windows: depositStart, depositEnd, redeemStart, redeemEnd"); + console2.log("==============================================================="); + console2.log(depositWindow.opensAt); + console2.log(depositWindow.closesAt); + console2.log(redemptionWindow.opensAt); + console2.log(redemptionWindow.closesAt); + console2.log("==============================================================="); + } +} diff --git a/packages/contracts/test/src/CredbullFixedYieldVaultStakingTest.t.sol b/packages/contracts/test/src/CredbullFixedYieldVaultStakingTest.t.sol new file mode 100644 index 000000000..b2db493dc --- /dev/null +++ b/packages/contracts/test/src/CredbullFixedYieldVaultStakingTest.t.sol @@ -0,0 +1,81 @@ +//SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import { Test } from "forge-std/Test.sol"; + +import { HelperConfig } from "@script/HelperConfig.s.sol"; +import { DeployStakingVaults } from "@script/DeployStakingVaults.s.sol"; +import { CredbullFixedYieldVault } from "@credbull/CredbullFixedYieldVault.sol"; + +import { CBL } from "@credbull/token/CBL.sol"; + +contract CredbullFixedYieldVaultStakingTest is Test { + CredbullFixedYieldVault private vault50APY; + CredbullFixedYieldVault private vault0APY; + HelperConfig private helperConfig; + + CBL private cbl; + + address private alice = makeAddr("alice"); + address private bob = makeAddr("bob"); + + address private owner; + address private operator; + address private minter; + + uint256 private precision; + uint256 private constant INITIAL_BALANCE = 1000; + + function setUp() public { + DeployStakingVaults deployStakingVaults = new DeployStakingVaults(); + (, vault50APY, vault0APY, helperConfig) = deployStakingVaults.run(); + + cbl = CBL(vault50APY.asset()); + precision = 10 ** cbl.decimals(); + + assertEq(10 ** 18, precision, "should be 10^18"); + + owner = helperConfig.getNetworkConfig().factoryParams.owner; + operator = helperConfig.getNetworkConfig().factoryParams.operator; + minter = helperConfig.getNetworkConfig().factoryParams.operator; + + vm.startPrank(minter); + cbl.mint(alice, INITIAL_BALANCE * precision); + cbl.mint(bob, INITIAL_BALANCE * precision); + vm.stopPrank(); + + assertEq(INITIAL_BALANCE * precision, cbl.balanceOf(alice), "alice didn't receive CBL"); + } + + function test__FixedYieldVaultStakingChallenge__Expect50APY() public { + uint256 depositAmount = 10 * precision; + uint256 expectedAssets = ((depositAmount * (100 + 50)) / 100); + + depositAndVerify(vault50APY, depositAmount, expectedAssets); + } + + function test__FixedYieldVaultStakingChallenge__Expect0APY() public { + uint256 depositAmount = 10 * precision; + + depositAndVerify(vault0APY, depositAmount, depositAmount); + } + + function depositAndVerify(CredbullFixedYieldVault vault, uint256 depositAmount, uint256 expectedAssets) public { + assertTrue(vault.checkWindow(), "window should be on"); + + vm.prank(owner); + vault.toggleWindowCheck(); + assertFalse(vault.checkWindow(), "window should be off"); + + vm.startPrank(alice); + cbl.approve(address(vault), depositAmount); + uint256 shares = vault.deposit(depositAmount, alice); + vm.stopPrank(); + + assertEq(depositAmount, cbl.balanceOf(vault.CUSTODIAN()), "custodian should have the CBL"); + assertEq(shares, vault.balanceOf(alice), "alice should have the shares"); + + assertEq(vault.expectedAssetsOnMaturity(), expectedAssets); + } +} diff --git a/packages/contracts/test/src/CredbullFixedYieldVaultTest.t.sol b/packages/contracts/test/src/CredbullFixedYieldVaultTest.t.sol index 7d64bec86..cc15a8e3f 100644 --- a/packages/contracts/test/src/CredbullFixedYieldVaultTest.t.sol +++ b/packages/contracts/test/src/CredbullFixedYieldVaultTest.t.sol @@ -15,10 +15,13 @@ import { CredbullWhiteListProvider } from "@credbull/CredbullWhiteListProvider.s import { WhiteListProvider } from "@credbull/provider/whiteList/WhiteListProvider.sol"; import { WhiteListPlugin } from "@credbull/plugin/WhiteListPlugin.sol"; import { FixedYieldVault } from "@credbull/vault/FixedYieldVault.sol"; +import { Vault } from "@credbull/vault/Vault.sol"; import { ParamsFactory } from "@test/test/vault/utils/ParamsFactory.t.sol"; import { SimpleUSDC } from "@test/test/token/SimpleUSDC.t.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + contract CredbullFixedYieldVaultTest is Test { CredbullFixedYieldVault private vault; HelperConfig private helperConfig; @@ -320,6 +323,47 @@ contract CredbullFixedYieldVaultTest is Test { assertEq(token.balanceOf(address(vault)), 0); } + function test__FixedYieldVault__DepositAssetsAndGetShares() public { + IERC20 asset = IERC20(vault.asset()); + uint256 custodiansBalance = asset.balanceOf(vault.CUSTODIAN()); + + address vaultAddress = address(vault); + // address receiver = makeAddr("receiver"); + address receiver = vaultAddress; + + // ---- Setup Part 1, Check balance before deposit ---- + assertEq(asset.balanceOf(address(vault)), 0, "Vault should start with no assets"); + assertEq(vault.totalAssets(), 0, "Vault should start with no assets"); + assertEq(vault.balanceOf(alice), 0, "User should start with no Shares"); + + // ---- Setup Part 2 - alice Deposit and receiver receives shares ---- + uint256 depositAmount = 10 * precision; + //Call internal deposit function + uint256 shares = deposit(vault, alice, receiver, depositAmount, false); + + // ---- Assert - Vault gets the Assets, Receiver gets Shares ---- + + // vault should have the assets + assertEq(vault.totalAssets(), depositAmount, "Vault should now have the assets"); + assertEq( + asset.balanceOf(vault.CUSTODIAN()), + depositAmount + custodiansBalance, + "Custodian should have received the assets" + ); + + // receiver should have the shares + assertEq(shares, depositAmount, "User should now have the Shares"); + assertEq(vault.balanceOf(receiver), depositAmount, "Receiver should now have the Shares"); + assertEq(vault.balanceOf(vaultAddress), depositAmount, "Vault should now have the Shares"); + + address[] memory addresses = new address[](1); + addresses[0] = address(vault); + + vm.prank(params.roles.owner); + vm.expectRevert(abi.encodeWithSelector(Vault.CredbullVault__TransferOutsideEcosystem.selector, address(vault))); + vault.withdrawERC20(addresses); + } + function deposit(address user, uint256 assets, bool warp) internal returns (uint256 shares) { return deposit(vault, user, assets, warp); } @@ -328,8 +372,18 @@ contract CredbullFixedYieldVaultTest is Test { internal returns (uint256 shares) { + return deposit(fixedYieldVault, user, user, assets, warp); + } + + function deposit( + CredbullFixedYieldVault fixedYieldVault, + address owner, + address receiver, + uint256 assets, + bool warp + ) internal returns (uint256 shares) { // first, approve the deposit - vm.startPrank(user); + vm.startPrank(owner); params.maturityVault.vault.asset.approve(address(fixedYieldVault), assets); // wrap if set to true @@ -337,7 +391,7 @@ contract CredbullFixedYieldVaultTest is Test { vm.warp(params.windowPlugin.depositWindow.opensAt); } - shares = fixedYieldVault.deposit(assets, user); + shares = fixedYieldVault.deposit(assets, receiver); vm.stopPrank(); } } diff --git a/packages/sdk/.gitignore b/packages/sdk/.gitignore index 383e232ba..ef1dc0121 100644 --- a/packages/sdk/.gitignore +++ b/packages/sdk/.gitignore @@ -5,6 +5,7 @@ node_modules/ /playwright/.cache/ dist/ build/ +logs/ # NOTE (JL,2024-05-28): TSC Project Reference artifact. **/*.tsbuildinfo diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 7087bc1c9..2612f9193 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -10,6 +10,8 @@ "build:ci": "yarn build", "int-test": "playwright test", "format": "prettier . --write", + "int-test-staking": "playwright test test/src/staking/vault-deposit.spec.ts -g \"Test Deposit 3\"", + "start-staking": "ts-node test/src/staking/vault-deposit-app.ts TEST-vault-deposit-3.json", "lint": "eslint --fix --ignore-path .gitignore", "report": "playwright show-report" }, diff --git a/packages/sdk/test/resource/TEST-vault-deposit-0.json b/packages/sdk/test/resource/TEST-vault-deposit-0.json new file mode 100644 index 000000000..a2a6b671e --- /dev/null +++ b/packages/sdk/test/resource/TEST-vault-deposit-0.json @@ -0,0 +1,3 @@ +{ + "VaultDeposits": [] +} diff --git a/packages/sdk/test/resource/TEST-vault-deposit-3.json b/packages/sdk/test/resource/TEST-vault-deposit-3.json new file mode 100644 index 000000000..81ab3e9fa --- /dev/null +++ b/packages/sdk/test/resource/TEST-vault-deposit-3.json @@ -0,0 +1,25 @@ +{ + "VaultDeposits": [ + { + "VaultDeposit": { + "id": 8, + "receiver": "0x40524fB22EbF46ac8593F9945b936e3aD1dC33ba", + "depositAmount": "8000000000000000000" + } + }, + { + "VaultDeposit": { + "id": 9, + "receiver": "0x9b7d876D89cf06a011f5F0949bd438C1654E1b8A", + "depositAmount": "9000000000000000000" + } + }, + { + "VaultDeposit": { + "id": 10, + "receiver": "0x1709Fd8718EE17A6f81c08a5D751381A9cb34619", + "depositAmount": "10000000000000000000" + } + } + ] +} diff --git a/packages/sdk/test/resource/test-local.toml b/packages/sdk/test/resource/test-local.toml index 2a8c38646..8f2089b06 100644 --- a/packages/sdk/test/resource/test-local.toml +++ b/packages/sdk/test/resource/test-local.toml @@ -19,10 +19,13 @@ owner = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" operator = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" # Dev/Anvil Wallet, Account[2] custodian = "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" -# Dev/Anvil Wallet, Account[3] -treasury = "0x90F79bf6EB2c4f870365E785982E1f101E93b906" -# Dev/Anvil Wallet, Account[4] -activity_reward = "0xcabE80b332Aa9d900f5e32DF51cb0Bc5b276c556" +# Dev/Anvil Wallet, Account[5] +treasury = "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc" +# Dev/Anvil Wallet, Account[9] +activity_reward = "0xa0Ee7A142d267C1f36714E4a8F75612F20a79720" +## CBL Staking Vault address - Anvil +vault_cbl_staking="0x0165878A594ca255338adfa4d48449f69242Eb8F" + [users] # Associated with Dev/Anvil Wallet, Account[0] diff --git a/packages/sdk/test/resource/test-mainnet.toml b/packages/sdk/test/resource/test-mainnet.toml new file mode 100644 index 000000000..4c05ebfd4 --- /dev/null +++ b/packages/sdk/test/resource/test-mainnet.toml @@ -0,0 +1,18 @@ +node_env = "prod" + +[api] +url = "" + +[app] +url = "" + +[services.supabase] +url = "" + +[services.ethers] +# TODO - need to add API key to URL +url = "https://arb-mainnet.g.alchemy.com/v2/" + +[evm.address] +# CBL Staking Vault address - Arbitrum One +vault_cbl_staking="0xe4a4d891f02DF7bFFc5ff9e691313DE8a9E76b91" diff --git a/packages/sdk/test/resource/test-testnet.toml b/packages/sdk/test/resource/test-testnet.toml new file mode 100644 index 000000000..5023b5df0 --- /dev/null +++ b/packages/sdk/test/resource/test-testnet.toml @@ -0,0 +1,54 @@ +node_env = "test" + +[api] +url = "" + +[app] +url = "" + +[services.supabase] +url = "" + +[services.ethers] +# TODO - need to add API key to URL +url = "https://arb-sepolia.g.alchemy.com/v2/" + +[evm.address] +# CBL Staking Vault address - Arbitrum Sepolia +vault_cbl_staking="0x810d489487C385fEb6ee4d7e2B16B5AED35E47e5" + +# credbull-devops wallets. wallet numbers are 1-based (as opposed to 0-based in anvil) +# devops admin/owner (wallet 1) - public address, okay to share +owner = "0xD79Be36f61fce3B8EF2FBF22b13B2b9a68eE15A2" +# devops operator (wallet 2) - public address, okay to share +operator = "0xaD3C004eE1f942BFDA2DA0D2DAaC94d6aC012F75" +# devops custodian (wallet 3) - public address, okay to share +custodian = "0x8561845F6a9511cD8e2daCae77A961e718A77cF6" + +# BELOW are used only in testing +# devops upgrader (wallet 4) - public address, okay to share +upgrader = "0x77f07B5d5E03e39Dc42FBCa53F122D4c1851B939" +# devops deployer (wallet 5) - public address, okay to share +deployer = "0x1dC62317f7B1d981eE16618678586460635300d3" +# devops treasury (wallet 6) - public address, okay to share +treasury = "0x0822a51f5f8DB0480A0eA294099D56EA1aCcdC5c" +# devops asset manager (wallet 7) - public address, okay to share +asset_manager = "0xd097E901FB9B75C2d2f97E142d73fA79C31FcAb3" +# devops alice (wallet 8) - public address, okay to share +alice = "0x40524fB22EbF46ac8593F9945b936e3aD1dC33ba" +# devops bob (wallet 9) - public address, okay to share +bob = "0x9b7d876D89cf06a011f5F0949bd438C1654E1b8A" +# devops charlie (wallet 9) - public address, okay to share +charlie = "0x1709Fd8718EE17A6f81c08a5D751381A9cb34619" + + +[users] +# Associated with Dev/Anvil Wallet, Account[0] +admin.email_address = "test+admin@credbull.io" +# Associated with Dev/Anvil Wallet, Account[7] +alice.email_address = "test+alice@credbull.io" +# Associated with Dev/Anvil Wallet, Account[8] +bob.email_address = "test+bob@credbull.io" + +[operation.createVault] +collateral_percentage = 20 diff --git a/packages/sdk/test/src/liquid-vault.spec.ts b/packages/sdk/test/src/liquid-vault.spec.ts index 6fac28025..f1a45c9a2 100644 --- a/packages/sdk/test/src/liquid-vault.spec.ts +++ b/packages/sdk/test/src/liquid-vault.spec.ts @@ -1,6 +1,6 @@ import { ERC20__factory, LiquidContinuousMultiTokenVault__factory } from '@credbull/contracts'; import { expect, test } from '@playwright/test'; -import {BigNumber, type BigNumberish, type CallOverrides, ethers} from 'ethers'; +import { BigNumber, ethers } from 'ethers'; import { TestSigners } from './utils/test-signer'; @@ -58,8 +58,8 @@ test.describe.skip('Test LiquidContinuousMultiTokenVault ethers operations', () const redeemPeriod = BigNumber.from(30).toNumber(); // redeemPeriod and requestId are equal const liquidVault = LiquidContinuousMultiTokenVault__factory.connect( - VAULT_PROXY_CONTRACT_ADDRESS, - user.getDelegate(), + VAULT_PROXY_CONTRACT_ADDRESS, + user.getDelegate(), ); const userAddress = await user.getAddress(); @@ -98,6 +98,4 @@ test.describe.skip('Test LiquidContinuousMultiTokenVault ethers operations', () expect((await liquidVault.unlockRequestAmount(userAddress, redeemPeriod)).toNumber()).toEqual(0); } }); - - }); diff --git a/packages/sdk/test/src/staking/vault-deposit-app.ts b/packages/sdk/test/src/staking/vault-deposit-app.ts new file mode 100644 index 000000000..59fd51119 --- /dev/null +++ b/packages/sdk/test/src/staking/vault-deposit-app.ts @@ -0,0 +1,76 @@ +import { CredbullFixedYieldVault, CredbullFixedYieldVault__factory } from '@credbull/contracts'; +import { Wallet, ethers, providers } from 'ethers'; + +import { Config, loadConfiguration } from '../utils/config'; +import { logger } from '../utils/logger'; + +import { Address, DepositStatus, VaultDeposit } from './vault-deposit'; +import { parseFromFile } from './vault-deposit-parser'; + +export class LoadDepositResult { + successes: VaultDeposit[] = []; + fails: VaultDeposit[] = []; + skipped: VaultDeposit[] = []; + + logSummary(): string { + return `Successes: ${this.successes.length}, Skipped: ${this.skipped.length}, Fails: ${this.fails.length}`; + } +} + +export class VaultDepositApp { + private _config: Config; + private _provider: providers.JsonRpcProvider; + private _tokenOwner: Wallet; + private _stakingVaultAddress: Address; + + constructor() { + this._config = loadConfiguration(); + this._provider = new ethers.providers.JsonRpcProvider(this._config.services.ethers.url); + this._stakingVaultAddress = this._config.evm.address.vault_cbl_staking; + + if (!this._config || !this._config.secret || !this._config.secret.DEPLOYER_PRIVATE_KEY) { + throw new Error(`Deployer configuration and key not defined.`); + } + this._tokenOwner = new ethers.Wallet(this._config.secret.DEPLOYER_PRIVATE_KEY, this._provider); + } + + async loadDeposits(filePath: string): Promise { + logger.warn('******************'); + logger.warn('Starting Staking App'); + + const result = new LoadDepositResult(); + + const vault: CredbullFixedYieldVault = CredbullFixedYieldVault__factory.connect( + this._stakingVaultAddress, + this._tokenOwner, + ); + + // TODO - check if the tokenOwner has any tokens - no point in loading anything if not + + // parse the deposits + const vaultDeposits: VaultDeposit[] = parseFromFile(filePath); + + logger.info('Begin Deposit all...'); + + for (const deposit of vaultDeposits) { + try { + const status = await deposit.deposit(this._tokenOwner, vault); + if (status === DepositStatus.Success) { + result.successes.push(deposit); + logger.info(`++ Deposit success: ${deposit.toString()}`); + } else if (status === DepositStatus.SkippedAlreadyProcessed) { + result.skipped.push(deposit); + logger.info(`== Deposit skipped (already processed): ${deposit.toString()}`); + } + } catch (error) { + logger.error(`-- Deposit failed !!!! ${deposit.toString()} . Error: ${String(error)}`); + throw error; + } + } + + logger.warn(`End Staking app. Result: ' ${result.logSummary()}`); + logger.warn('******************'); + + return result; + } +} diff --git a/packages/sdk/test/src/staking/vault-deposit-parser.spec.ts b/packages/sdk/test/src/staking/vault-deposit-parser.spec.ts new file mode 100644 index 000000000..6f2575848 --- /dev/null +++ b/packages/sdk/test/src/staking/vault-deposit-parser.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test'; +import { ethers } from 'ethers'; + +import { VaultDeposit } from './vault-deposit'; +import { parseFromFile } from './vault-deposit-parser'; + +const TEST_VAULT_DEPOSIT_3_FILENAME = 'TEST-vault-deposit-3.json'; + +test.describe('Test Vault Deposit loader', () => { + test('Test parse from file', async () => { + const vaultDeposits: VaultDeposit[] = parseFromFile(TEST_VAULT_DEPOSIT_3_FILENAME); + expect(vaultDeposits.length).toBeGreaterThanOrEqual(1); + + for (const vaultDeposit of vaultDeposits) { + expect(vaultDeposit._id).toBeGreaterThanOrEqual(1); + expect(vaultDeposit._depositAmount.toBigInt()).toBeGreaterThanOrEqual(BigInt(1e18)); + expect(vaultDeposit._receiver).not.toBeNull(); + expect(ethers.utils.isAddress(vaultDeposit._receiver)).toBe(true); + } + }); +}); diff --git a/packages/sdk/test/src/staking/vault-deposit-parser.ts b/packages/sdk/test/src/staking/vault-deposit-parser.ts new file mode 100644 index 000000000..52a7251da --- /dev/null +++ b/packages/sdk/test/src/staking/vault-deposit-parser.ts @@ -0,0 +1,30 @@ +// vaultDepositLoader.ts +import { BigNumber } from 'ethers'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { logger } from '../utils/logger'; + +import { VaultDeposit } from './vault-deposit'; + +const resourcePath = path.join(__dirname, '../../resource'); + +export function parseFromFile(filePath: string): VaultDeposit[] { + logger.info(`Vault Deposit Parser parsing file ${filePath}`); + + const data = fs.readFileSync(path.resolve(resourcePath, filePath), 'utf-8'); + const jsonData = JSON.parse(data); + + if (!jsonData.VaultDeposits || !Array.isArray(jsonData.VaultDeposits)) { + throw new Error("Invalid format: Expected 'vaultDeposits' array in JSON file."); + } + + return jsonData.VaultDeposits.map((entry: any) => { + const deposit = entry.VaultDeposit; + if (!deposit || typeof deposit !== 'object') { + throw new Error("Invalid format: Each entry in 'VaultDeposits' should contain a 'VaultDeposit' object."); + } + + return new VaultDeposit(deposit.id, deposit.receiver, BigNumber.from(deposit.depositAmount)); + }); +} diff --git a/packages/sdk/test/src/staking/vault-deposit.spec.ts b/packages/sdk/test/src/staking/vault-deposit.spec.ts new file mode 100644 index 000000000..e362c6fbf --- /dev/null +++ b/packages/sdk/test/src/staking/vault-deposit.spec.ts @@ -0,0 +1,82 @@ +import { expect, test } from '@playwright/test'; +import { BigNumber } from 'ethers'; +import * as path from 'path'; + +import { createProcessedLogger, initProcessedLogCache, processedLogCache } from '../utils/logger'; + +import { VaultDeposit } from './vault-deposit'; +import { LoadDepositResult, VaultDepositApp } from './vault-deposit-app'; + +test.beforeAll(async () => {}); + +export const TEST_VAULT_DEPOSIT_0_FILENAME = 'TEST-vault-deposit-0.json'; +export const TEST_VAULT_DEPOSIT_3_FILENAME = 'TEST-vault-deposit-3.json'; +export const TEST_VAULT_DEPOSIT_50_FILENAME = 'TEST-vault-deposit-50.json'; +export const TEST_VAULT_DEPOSIT_1000_FILENAME = 'TEST-vault-deposit-1000.json'; + +test.describe('Test Vault Deposit for all', () => { + test('Test Deposit empty json should process nothing', async () => { + const vaultDepositApp = new VaultDepositApp(); + + const resultEmpty: LoadDepositResult = await vaultDepositApp.loadDeposits(TEST_VAULT_DEPOSIT_0_FILENAME); + expect(resultEmpty.successes.length).toBe(0); + expect(resultEmpty.fails.length).toBe(0); + expect(resultEmpty.skipped.length).toBe(0); + }); +}); + +test.describe('Test VaultDeposit Utility functions', () => { + const vaultDeposit = new VaultDeposit( + -1, + '0x14dC79964da2C08b23698B3D3cc7Ca32193d9955', + BigNumber.from('7000000000000000000'), + ); + + test('should set all fields on json', async () => { + // Act: Call the toJson method + const jsonResult = vaultDeposit.toJson(); + + // Assert: Check that jsonResult matches the expected JSON structure and values + expect(jsonResult).toEqual({ + VaultDeposit: { + id: vaultDeposit._id, + receiver: vaultDeposit._receiver, + depositAmount: vaultDeposit._depositAmount.toString(), + }, + }); + }); + + test('should log as json and determine if processed', async () => { + const chainId = 31137; + + const testLogFilePath = path.join(__dirname, '../../../logs/test-staking-processed.json'); + + // Clear log cache and initialize it for the test log file + processedLogCache.length = 0; + initProcessedLogCache(testLogFilePath); + + // Create a test-specific logger pointing to test-staking-processed.json + const testProcessedLogger = createProcessedLogger(testLogFilePath); + + expect(await vaultDeposit.isProcessed(chainId, processedLogCache)).toBe(false); // Should not be processed initially + + // Act: Call logResult using the test-specific logger + const txnHash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + await vaultDeposit.logResult(chainId, txnHash, testProcessedLogger); + + // Check if the log entry is correctly stored in logMessages + expect(processedLogCache[0]).toEqual({ + level: 'info', + message: { + chainId: chainId, + txnHash: txnHash, + ...vaultDeposit.toJson(), + }, + timestamp: expect.any(String), // Include timestamp since it's auto-generated + }); + + console.log(`%% Test isProcessed LogMessage: ${JSON.stringify(processedLogCache[0], null, 2)}`); + + expect(await vaultDeposit.isProcessed(chainId, processedLogCache)).toBe(true); // Now processed + }); +}); diff --git a/packages/sdk/test/src/staking/vault-deposit.ts b/packages/sdk/test/src/staking/vault-deposit.ts new file mode 100644 index 000000000..a39c32980 --- /dev/null +++ b/packages/sdk/test/src/staking/vault-deposit.ts @@ -0,0 +1,148 @@ +import { CredbullFixedYieldVault, OwnableToken, OwnableToken__factory } from '@credbull/contracts'; +import * as assert from 'assert'; +import { BigNumber, Wallet, ethers } from 'ethers'; + +import { handleError } from '../utils/decoder'; +import { logger, processedLogCache, processedLogger } from '../utils/logger'; + +export type Address = string; + +export enum DepositStatus { + Success = 'Success', + SkippedAlreadyProcessed = 'SkippedAlreadyProcessed', +} + +export class VaultDeposit { + constructor( + public readonly _id: number, + public readonly _receiver: Address, + public readonly _depositAmount: BigNumber, + ) {} + + async deposit(owner: Wallet, vault: CredbullFixedYieldVault): Promise { + logger.info('------------------'); + logger.info(`Begin Deposit ${this.toString()} from: ${owner.address}`); + + let depositStatus = undefined; + + const alreadyProcessed = await this.isProcessed(await owner.getChainId(), processedLogCache); + if (alreadyProcessed) { + depositStatus = DepositStatus.SkippedAlreadyProcessed; + } else { + await this.allowance(owner, vault); + await this.depositOnly(vault); + depositStatus = DepositStatus.Success; + } + + logger.debug(`End Deposit [id=${this._id}]`); + logger.info('------------------'); + return depositStatus; + } + + async depositOnly(vault: CredbullFixedYieldVault) { + logger.debug(`Depositing [id=${this._id}] ...`); + + const prevVaultBalanceReceiver = await vault.balanceOf(this._receiver); + + // TODO - check if the same address will have multiple deposits. if not, we can skip any deposits that already exist. + + // -------------------------- Simulate -------------------------- + + // Simulate the deposit to get the return value (shares) without sending a transaction + const shares = await vault.callStatic.deposit(this._depositAmount, this._receiver).catch((err) => { + const decodedError = handleError(vault, err); + logger.error('CallStatic deposit error:', decodedError.message); + throw decodedError; + }); + + logger.debug(`Deposit simulated shares: ${shares.toString()}`); + + // -------------------------- Deposit -------------------------- + + const depositTxnResponse = await vault.deposit(this._depositAmount, this._receiver).catch((err) => { + const decodedError = handleError(vault, err); + logger.error('Deposit contract error:', decodedError.message); + throw decodedError; + }); + + // wait for the transaction to be mined + const receipt = await depositTxnResponse.wait(); + logger.info( + `Deposit Processed ${this.toString()} Txn status: ${receipt.status} Txn hash: ${depositTxnResponse.hash}`, + ); + await this.logResult(depositTxnResponse.chainId, depositTxnResponse.hash); + + const expectedBalance = this._depositAmount.add(prevVaultBalanceReceiver); + const receiverBalance = await vault.balanceOf(this._receiver); + if (!expectedBalance.eq(receiverBalance)) { + logger.error( + `!!!! Balance not correct after deposit [id=${this._id}]!!!! Expected: ${expectedBalance} (${prevVaultBalanceReceiver} + ${this._depositAmount.toString()}), but was: ${receiverBalance.toString()}`, + ); + } + + logger.debug(`End Deposit Only [id=${this._id}].`); + } + + async allowance(owner: Wallet, vault: CredbullFixedYieldVault) { + const assetAddress: string = await vault.asset(); + const tokenAsOwner: OwnableToken = OwnableToken__factory.connect(assetAddress, owner); + const ownerAddress = await owner.getAddress(); + + //const allowanceToGrant = this._depositAmount.sub(await tokenAsOwner.allowance(ownerAddress, vault.address)); + + logger.debug(`Granting Allowance [id=${this._id}] ...`); + + const approveTxnResponse = await tokenAsOwner.approve(vault.address, this._depositAmount).catch((err) => { + const decodedError = handleError(vault, err); + logger.error('Approval contract error:', decodedError.message); + throw decodedError; + }); + + // wait for the transaction to be mined + await approveTxnResponse.wait(); + + const allowance = await tokenAsOwner.allowance(ownerAddress, vault.address); + assert.ok( + this._depositAmount.gte(allowance), + `Allowance not granted [id=${this._id}]. Expected: ${this._depositAmount.toString()}, but was: ${allowance.toString()}`, + ); + } + + async isProcessed(chainId: number, processedLogMessages: any[]): Promise { + const alreadyProcessed = processedLogMessages.some( + (entry) => + entry.message && + entry.message.chainId === chainId && + entry.message.VaultDeposit && + entry.message.VaultDeposit.id === this._id, + ); + + if (alreadyProcessed) { + logger.debug(`Skipping VaultDeposit with id ${this._id} on chain ${chainId}. Already processed.`); + } + + return alreadyProcessed; + } + + async logResult(chainId: number, txnHash: string, customLogger = processedLogger) { + customLogger.info({ + chainId, + ...this.toJson(), // Spread the JSON representation of the deposit + txnHash, // Add the transaction hash + }); + } + + toString(): string { + return `VaultDeposit [id: ${this._id}, Receiver: ${this._receiver}, Amount: ${this._depositAmount.toString()}]`; + } + + toJson(): object { + return { + VaultDeposit: { + id: this._id, + receiver: this._receiver, + depositAmount: this._depositAmount.toString(), // Convert BigNumber to string for JSON serialization + }, + }; + } +} diff --git a/packages/sdk/test/src/staking/vault-utility.spec.ts b/packages/sdk/test/src/staking/vault-utility.spec.ts new file mode 100644 index 000000000..682fc98b1 --- /dev/null +++ b/packages/sdk/test/src/staking/vault-utility.spec.ts @@ -0,0 +1,219 @@ +import { CredbullFixedYieldVault__factory, ERC20__factory, OwnableToken__factory } from '@credbull/contracts'; +import { expect, test } from '@playwright/test'; +import { Wallet, ethers } from 'ethers'; + +import { Config, loadConfiguration } from '../utils/config'; +import { handleError } from '../utils/decoder'; +import { TestSigners } from '../utils/test-signer'; + +const VAULT_MAX_CAP = '10000000'; + +let provider: ethers.providers.JsonRpcProvider; +let config: Config; + +let ownerSigner: Wallet; +let operatorSigner: Wallet; +let custodianSigner: Wallet; +let userSigner: Wallet; + +let stakingVaultAddress: string; + +test.beforeAll(async () => { + config = loadConfiguration(); + provider = new ethers.providers.JsonRpcProvider(config.services.ethers.url); + const testSigners: TestSigners = new TestSigners(provider); + + ownerSigner = testSigners.admin.getDelegate(); + operatorSigner = testSigners.operator.getDelegate(); + custodianSigner = testSigners.custodian.getDelegate(); + userSigner = new ethers.Wallet(config.secret.DEPLOYER_PRIVATE_KEY, provider); + + stakingVaultAddress = config.evm.address.vault_cbl_staking; +}); + +test.describe('Test Credbull Staking Challenge read operations', () => { + test('Test read operations', async () => { + const vault = await connectVault(stakingVaultAddress, userSigner); + + const token = await connectToken(await vault.asset(), userSigner); + expect(token.decimals()).resolves.toEqual(18); + + // check the toggles + expect(await vault.checkMaxCap()).toEqual(true); + expect(await vault.checkWhiteList()).toEqual(true); + + // check the balances + console.log('Owner CBL balance = %s', (await token.balanceOf(ownerSigner.address)).toBigInt().toString()); + console.log('Operator CBL balance = %s', (await token.balanceOf(operatorSigner.address)).toBigInt().toString()); + console.log('Custodian CBL balance = %s', (await token.balanceOf(custodianSigner.address)).toBigInt().toString()); + console.log('User CBL balance = %s', (await token.balanceOf(userSigner.address)).toBigInt().toString()); + console.log('Vault CBL balance = %s', (await token.balanceOf(vault.address)).toBigInt().toString()); + + console.log('------------------ connected to vault ----------------'); + console.log(await vault.symbol()); + console.log('------------------ end ----------------'); + }); +}); + +test.describe('Test Credbull Staking Challenge Owner updates', () => { + test('Test turn off window checking', async () => { + const vault = CredbullFixedYieldVault__factory.connect(stakingVaultAddress, ownerSigner); + + // turn off window checking + if (await vault.checkWindow()) { + await vault.toggleWindowCheck(); + } else { + console.log('Window already toggled off, skipping'); + } + + expect(await vault.checkWindow()).toEqual(false); // now should be off + }); + + test('Update max cap to be the right precision', async () => { + const maxcap = ethers.utils.parseEther(VAULT_MAX_CAP); // 10 million ETH in wei + + const vault = CredbullFixedYieldVault__factory.connect(stakingVaultAddress, ownerSigner); + + // check the asset + const usdc = ERC20__factory.connect(await vault.asset(), ownerSigner); + expect(usdc.decimals()).resolves.toEqual(18); + + const maxCap = await vault.maxCap(); + console.log('Vault maximum cap:', maxCap.toString()); + + expect(await vault.checkMaxCap()).toEqual(true); + console.log('maxcap=%s', (await vault.maxCap()).toBigInt()); + + if ((await vault.maxCap()).toBigInt() != maxcap.toBigInt()) { + console.log('Updating max cap...'); + await vault.updateMaxCap(maxcap); // update the max cap + } else { + console.log('Max cap already updated, skipping'); + } + + expect((await vault.maxCap()).toBigInt()).toEqual(maxcap.toBigInt()); // window starts on + }); +}); + +test.describe('Test Credbull Staking Challenge Vault Mint', () => { + const depositAmount = ethers.utils.parseEther('1000000'); + + test('Test Mint', async () => { + const vaultAsUser = CredbullFixedYieldVault__factory.connect(stakingVaultAddress, userSigner); + const assetAddress = await vaultAsUser.asset(); + const tokenAsUser = await connectToken(assetAddress, userSigner); + + // ------------------------- Mint ------------------------- + const userAddress = await userSigner.getAddress(); + await (await connectToken(assetAddress, operatorSigner)).mint(userAddress, depositAmount); + + expect((await tokenAsUser.balanceOf(userAddress)).toBigInt()).toBeGreaterThanOrEqual(depositAmount.toBigInt()); // window starts on + }); +}); + +test.describe('Test Credbull Staking Challenge Redeem', () => { + test('Test turn off maturity checking', async () => { + const vault = CredbullFixedYieldVault__factory.connect(stakingVaultAddress, ownerSigner); + + // turn off window checking + if (await vault.checkMaturity()) { + await vault.setMaturityCheck(false); + } else { + console.log('Maturity already off, skipping'); + } + + expect(await vault.checkMaturity()).toEqual(false); // now should be off + }); + + test('As Custodian, Move Assets into Vault', async () => { + const vaultAsCustodian = CredbullFixedYieldVault__factory.connect(stakingVaultAddress, custodianSigner); + const assetAddress = await vaultAsCustodian.asset(); + const tokenAsCustodian = await connectToken(assetAddress, custodianSigner); + + const tokenAsCustodianAddress = await custodianSigner.getAddress(); + const custodianBalance = await tokenAsCustodian.balanceOf(tokenAsCustodianAddress); + + console.log( + 'Transferring assets to vault as custodian %s balance of... ', + await custodianSigner.address, + custodianBalance.toBigInt(), + ); + + // now transfer + await tokenAsCustodian.transfer(stakingVaultAddress, custodianBalance).catch((err) => { + const decodedError = handleError(tokenAsCustodian, err); + console.error('Transfer contract error:', decodedError.message); + throw decodedError; + }); + + expect((await tokenAsCustodian.balanceOf(stakingVaultAddress)).toBigInt()).toBeGreaterThanOrEqual( + custodianBalance.toBigInt(), + ); + expect((await tokenAsCustodian.balanceOf(tokenAsCustodianAddress)).toNumber()).toEqual(0); // window starts on + }); + + test('Test Redeem', async () => { + const vaultAsUser = CredbullFixedYieldVault__factory.connect(stakingVaultAddress, userSigner); + + const userAddress = await userSigner.getAddress(); + const prevUserBalance = await vaultAsUser.balanceOf(userAddress); + + // ------------------------- Deposit ------------------------- + console.log('Redeeming as user %s... ' + userAddress); + + const shares = await vaultAsUser.balanceOf(userAddress); + + // now redeem + await vaultAsUser.redeem(shares, userAddress, userAddress).catch((err) => { + const decodedError = handleError(vaultAsUser, err); + console.error('Redeem contract error:', decodedError.message); + throw decodedError; + }); + + const newUserBalance = await vaultAsUser.balanceOf(userAddress); + const expectedBalance = prevUserBalance.sub(shares); + console.log( + 'prevBalance=%s, newBalance=%s, expected=%s' + prevUserBalance.toBigInt(), + newUserBalance.toBigInt(), + expectedBalance.toBigInt(), + ); + expect(newUserBalance.toBigInt()).toEqual(expectedBalance.toBigInt()); + }); + + test('Test turn on maturity checking', async () => { + const vault = CredbullFixedYieldVault__factory.connect(stakingVaultAddress, ownerSigner); + + // turn off window checking + if (!(await vault.checkMaturity())) { + await vault.setMaturityCheck(true); + } else { + console.log('Maturity already on, skipping'); + } + + expect(await vault.checkMaturity()).toEqual(true); // now should be off + }); +}); + +async function connectToken(assetAddress: string, wallet: Wallet) { + console.log('Connecting to Token...'); + + const token = OwnableToken__factory.connect(assetAddress, wallet); + expect(token.decimals()).resolves.toEqual(18); + + const TOKEN_SYMBOL = config.node_env == 'development' ? 'SMPL' : 'CBL'; + expect(token.symbol()).resolves.toEqual(TOKEN_SYMBOL); + + console.log('Connected to Token! ' + (await token.symbol())); + return token; +} + +async function connectVault(vaultAddress: string, wallet: Wallet) { + console.log('Connecting to vault...'); + + const vault = CredbullFixedYieldVault__factory.connect(vaultAddress, wallet); + expect(await vault.symbol()).toEqual('iceCBLsc'); + + console.log('Connected to Vault! ' + (await vault.symbol())); + + return vault; +} diff --git a/packages/sdk/test/src/utils/config.ts b/packages/sdk/test/src/utils/config.ts index 3b49a37dc..628f1c1c7 100644 --- a/packages/sdk/test/src/utils/config.ts +++ b/packages/sdk/test/src/utils/config.ts @@ -15,12 +15,13 @@ dotenv.config({ override: true, }); -interface Config { +export interface Config { secret?: { SUPABASE_SERVICE_ROLE_KEY?: string; SUPABASE_ANONYMOUS_KEY?: string; ADMIN_PASSWORD?: string; ADMIN_PRIVATE_KEY?: string; + DEPLOYER_PRIVATE_KEY?: string; ALICE_PASSWORD?: string; ALICE_PRIVATE_KEY?: string; BOB_PASSWORD?: string; @@ -44,7 +45,7 @@ export const loadConfiguration = (): Config => { const toml = fs.readFileSync(configFile, 'utf8'); const config: Config = load(toml); - console.log(`Successfully loaded configuration from: '${configFile}'`); + console.log('Successfully loaded configuration:', JSON.stringify(config, null, 2)); // include Environment into config // NB - call this after the log statement to avoid logging keys! @@ -53,6 +54,7 @@ export const loadConfiguration = (): Config => { config.secret.SUPABASE_ANONYMOUS_KEY = process.env.SUPABASE_ANONYMOUS_KEY; config.secret.ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; config.secret.ADMIN_PRIVATE_KEY = process.env.ADMIN_PRIVATE_KEY; + config.secret.DEPLOYER_PRIVATE_KEY = process.env.DEPLOYER_PRIVATE_KEY; config.secret.ALICE_PASSWORD = process.env.ALICE_PASSWORD; config.secret.ALICE_PRIVATE_KEY = process.env.ALICE_PRIVATE_KEY; config.secret.BOB_PASSWORD = process.env.BOB_PASSWORD; diff --git a/packages/sdk/test/src/utils/decoder.ts b/packages/sdk/test/src/utils/decoder.ts new file mode 100644 index 000000000..1fe8f6802 --- /dev/null +++ b/packages/sdk/test/src/utils/decoder.ts @@ -0,0 +1,30 @@ +import { ethers } from 'ethers'; + +export function decodeContractError(contract: ethers.Contract, errorData: string) { + const contractInterface = contract.interface; + const selecter = errorData.slice(0, 10); + const errorFragment = contractInterface.getError(selecter); + const res = contractInterface.decodeErrorResult(errorFragment, errorData); + const errorInputs = errorFragment.inputs; + + let message; + if (errorInputs.length > 0) { + message = errorInputs + .map((input, index) => { + return `${input.name}: ${res[index].toString()}`; + }) + .join(', '); + } + + return new Error(`${errorFragment.name} | ${message ? message : ''}`); +} + +export function handleError(contract: ethers.Contract, error: any) { + const errorToDecode = error.error?.data?.data ?? error.error?.error?.error?.data ?? error.error?.error?.data ?? ''; + + if (errorToDecode != '') { + return decodeContractError(contract, errorToDecode); + } + + return new Error(error); +} diff --git a/packages/sdk/test/src/utils/logger.spec.ts b/packages/sdk/test/src/utils/logger.spec.ts new file mode 100644 index 000000000..5df1f00b3 --- /dev/null +++ b/packages/sdk/test/src/utils/logger.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { createProcessedLogger, initProcessedLogCache, processedLogCache } from '../utils/logger'; + +const testLogFilePath = path.join(__dirname, '../../../logs/test-staking-processed.json'); + +test.describe('Logger Initialization with Custom File Path', () => { + test.beforeEach(() => { + // Create test log file with sample entries + fs.writeFileSync( + testLogFilePath, + [ + JSON.stringify({ + level: 'info', + message: { chainId: 31137, VaultDeposit: { id: 1, receiver: '0x123', depositAmount: '1000' } }, + timestamp: '2024-11-02T00:00:00Z', + }), + JSON.stringify({ + level: 'info', + message: { chainId: 31137, VaultDeposit: { id: 2, receiver: '0x456', depositAmount: '2000' } }, + timestamp: '2024-11-02T01:00:00Z', + }), + ].join('\n'), + ); + + initProcessedLogCache(testLogFilePath); // Load cache from the test file + }); + + test.afterEach(() => { + fs.unlinkSync(testLogFilePath); // Clean up the test log file + processedLogCache.length = 0; // Clear in-memory log cache + }); + + test('should load processedLogCache with entries from a custom file path', () => { + createProcessedLogger(testLogFilePath); // Use the test-specific logger + + expect(processedLogCache).toHaveLength(2); + expect(processedLogCache).toEqual([ + { + level: 'info', + message: { chainId: 31137, VaultDeposit: { id: 1, receiver: '0x123', depositAmount: '1000' } }, + timestamp: '2024-11-02T00:00:00Z', + }, + { + level: 'info', + message: { chainId: 31137, VaultDeposit: { id: 2, receiver: '0x456', depositAmount: '2000' } }, + timestamp: '2024-11-02T01:00:00Z', + }, + ]); + }); +}); diff --git a/packages/sdk/test/src/utils/logger.ts b/packages/sdk/test/src/utils/logger.ts new file mode 100644 index 000000000..5d4f7a44a --- /dev/null +++ b/packages/sdk/test/src/utils/logger.ts @@ -0,0 +1,64 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { PassThrough } from 'stream'; +import * as winston from 'winston'; + +const logDirectory = path.join(__dirname, '../../../logs'); +export const processedLogCache: any[] = []; + +const processedLogFilePath = path.join(logDirectory, 'staking-processed.json'); + +function initProcessedLogCache(filePath = processedLogFilePath) { + console.log('Loading log messages into cache...'); + processedLogCache.length = 0; // Clear existing cache + if (!fs.existsSync(filePath)) { + return; + } + const fileData = fs.readFileSync(filePath, 'utf-8'); + fileData + .trim() + .split('\n') + .forEach((line) => { + if (line) { + processedLogCache.push(JSON.parse(line)); + } + }); +} + +const logStream = new PassThrough(); +logStream.on('data', (chunk) => { + processedLogCache.push(JSON.parse(chunk.toString())); // Parse and store each log entry +}); + +const logger = winston.createLogger({ + format: winston.format.combine( + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.printf(({ timestamp, level, message }) => `${timestamp} [${level.toUpperCase()}]: ${message}`), + ), + transports: [ + new winston.transports.Console({ level: 'warn' }), + new winston.transports.File({ filename: path.join(logDirectory, 'staking.log'), level: 'info' }), + ], +}); + +function createProcessedLogger(filePath = processedLogFilePath) { + const logStream = new PassThrough(); + logStream.on('data', (chunk) => { + processedLogCache.push(JSON.parse(chunk.toString())); // Store each log entry in the cache + }); + + return winston.createLogger({ + level: 'info', + format: winston.format.combine(winston.format.timestamp(), winston.format.json()), + transports: [ + new winston.transports.File({ filename: filePath, level: 'info' }), + new winston.transports.Stream({ stream: logStream, level: 'info' }), // In-memory transport + ], + }); +} + +initProcessedLogCache(); + +const processedLogger = createProcessedLogger(); + +export { logger, processedLogger, initProcessedLogCache, createProcessedLogger }; diff --git a/packages/sdk/test/src/utils/test-signer.spec.ts b/packages/sdk/test/src/utils/test-signer.spec.ts index ac6130f31..128a7cf61 100644 --- a/packages/sdk/test/src/utils/test-signer.spec.ts +++ b/packages/sdk/test/src/utils/test-signer.spec.ts @@ -1,26 +1,29 @@ import { expect, test } from '@playwright/test'; import { BigNumber, Wallet, ethers } from 'ethers'; -import { OWNER_PUBLIC_KEY_LOCAL, TestSigner, TestSigners } from './test-signer'; +import { Config, loadConfiguration } from './config'; +import { TestSigner, TestSigners } from './test-signer'; let provider: ethers.providers.JsonRpcProvider; let testSigners: TestSigners; +let config: Config; test.beforeAll(async () => { - provider = new ethers.providers.JsonRpcProvider(); // no url, defaults to 'http://localhost:8545' + config = loadConfiguration(); + + provider = new ethers.providers.JsonRpcProvider(config.services.ethers.url); testSigners = new TestSigners(provider); }); // See: https://github.com/safe-global/safe-core-sdk/tree/main/packages/protocol-kit test.describe('Test signers', () => { - test('Create a signer from the first account', async () => { - const owner = new TestSigner(0, provider).getDelegate(); + test('TestSigners admin should be the first account', async () => { + const ownerWallet = new ethers.Wallet(config.secret.ADMIN_PRIVATE_KEY, provider); - expect(await owner.getAddress()).toEqual(OWNER_PUBLIC_KEY_LOCAL); - }); + const owner = new TestSigner(0, provider).getDelegate(); - test('TestSigners signer address should be the first account', async () => { - expect(await testSigners.admin.getAddress()).toEqual(OWNER_PUBLIC_KEY_LOCAL); + expect(await owner.getAddress()).toEqual(ownerWallet.address); + expect(await testSigners.admin.getAddress()).toEqual(ownerWallet.address); }); test('Admin signer sends 1 ETH', async () => { diff --git a/packages/sdk/test/src/utils/test-signer.ts b/packages/sdk/test/src/utils/test-signer.ts index be931c945..41b036437 100644 --- a/packages/sdk/test/src/utils/test-signer.ts +++ b/packages/sdk/test/src/utils/test-signer.ts @@ -1,16 +1,27 @@ -import { Signer, Wallet, ethers, providers } from 'ethers'; - -export const OWNER_PUBLIC_KEY_LOCAL: string = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; +import * as dotenv from 'dotenv'; +import { Wallet, ethers, providers } from 'ethers'; +import * as path from 'path'; + +// NOTE (JL,2024-05-20): Hierarchical Environments are loaded from the package's grandparent directory (../..), +// then the parent (..) and finally the package directory (.) (adjusted for module location). +dotenv.config({ + encoding: 'utf-8', + path: [ + path.resolve(__dirname, '../../../../../.env'), // credbull-defi (root) + path.resolve(__dirname, '../../../../.env'), // packages + path.resolve(__dirname, '../../../.env'), // sdk + ], + override: true, +}); export class TestSigner { private _delegate: Wallet; constructor(index: number, provider: providers.JsonRpcProvider) { - // TODO: the SDK is expecting a Wallet (that extends Signer). using mnemonic to set this for now. - const anvilMnemonic = 'test test test test test test test test test test test junk'; const path = `m/44'/60'/0'/0/${index}`; - const hdNode = ethers.utils.HDNode.fromMnemonic(anvilMnemonic); + + const hdNode = ethers.utils.HDNode.fromMnemonic(process.env.TEST_MNEMONIC || anvilMnemonic); this._delegate = new ethers.Wallet(hdNode.derivePath(path), provider); } @@ -20,7 +31,7 @@ export class TestSigner { return address; } - getDelegate(): Signer { + getDelegate(): Wallet { return this._delegate; } diff --git a/yarn.lock b/yarn.lock index c516f69ef..1603273d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -684,6 +684,7 @@ __metadata: version: 0.0.0-use.local resolution: "@credbull/repo@workspace:." dependencies: + "@types/winston": ^2.4.4 husky: ^9.0.11 lint-staged: ^15.2.7 turbo: ^2.0.6 @@ -4656,6 +4657,15 @@ __metadata: languageName: node linkType: hard +"@types/winston@npm:^2.4.4": + version: 2.4.4 + resolution: "@types/winston@npm:2.4.4" + dependencies: + winston: "*" + checksum: 69b2be354ee8f2685cd1ce4f0c22e5a8edbd5f3fb6cf400bf0b07299daf5cdbdfb3e4f602aa318e6b21a108164aa921958cd09324088604a7affb92e99282087 + languageName: node + linkType: hard + "@types/ws@npm:^8.5.10": version: 8.5.12 resolution: "@types/ws@npm:8.5.12" @@ -18193,6 +18203,25 @@ __metadata: languageName: node linkType: hard +"winston@npm:*": + version: 3.16.0 + resolution: "winston@npm:3.16.0" + dependencies: + "@colors/colors": ^1.6.0 + "@dabh/diagnostics": ^2.0.2 + async: ^3.2.3 + is-stream: ^2.0.0 + logform: ^2.6.0 + one-time: ^1.0.0 + readable-stream: ^3.4.0 + safe-stable-stringify: ^2.3.1 + stack-trace: 0.0.x + triple-beam: ^1.3.0 + winston-transport: ^4.7.0 + checksum: 2b01d51d8dc355c7eec67fe26f1f9642a5d4fb4ad3db38612229fa95c7c925fba7cbe3d87d362f36c433f9ad15032d455f96159a5bbe77166c4a8cf440127a6d + languageName: node + linkType: hard + "winston@npm:^3.11.0": version: 3.14.2 resolution: "winston@npm:3.14.2"