diff --git a/my_scripts/contracts.json b/my_scripts/contracts.json index 8535f87c..46426e0c 100644 --- a/my_scripts/contracts.json +++ b/my_scripts/contracts.json @@ -1 +1 @@ -{"class_hashes":{"Vault":"0x5f3e4d3a6f6ea274d0288b7320428e9960298b9db1f6848b7805d14ace6413e","VaultAllocator":"0x7608b7b98f28a18841285367907f2c8bb924949e9f610f370b190e106cd3c3f","RedeemRequest":"0x7421d403cf73830acc41ebc9646c74f4a931f747ac275e32d82fc3a7c7a9aef","Manager":"0x7dbf4bc6ebf73952e77d60a7da8b1ec1523cfd3477ed99253403a3e0cab40d0","SimpleDecoderAndSanitizer":"0x19c38fa79f607d0935596802bcee103b88bc81dfa4b9f2d8bf0e398b19ec3c8"},"contracts":{"Vault":"0x6a346bda4e723d3f4763513007d4b8ef0029f491ed0a1e0626db6d7f3af3c01","RedeemRequest":"0x66c5f84e5fc6c20545737cc187289adccacfeb9829da6fd4ddf6121b32573dc","VaultAllocator":"0x292503a0bff97ad8818481cca50f5f9298537d0e0e9dc6a7ac9bbb8a93f71a3","Manager":"0x2d7822d1616f5e0b556c0f7467eb968bd2acd0f2b2b8623adff96a51e5516db","SimpleDecoderAndSanitizer":"0x7b6f98311af8aa425278570e62abf523e6462eaa01a38c1feab9b2f416492e2","aum_oracle":"0x149298ade3e79ec6cbdac6cfad289c57504eaf54e590939136ed1ceca60c345"}} \ No newline at end of file +{"class_hashes":{"Vault":"0x5f3e4d3a6f6ea274d0288b7320428e9960298b9db1f6848b7805d14ace6413e","VaultAllocator":"0x7608b7b98f28a18841285367907f2c8bb924949e9f610f370b190e106cd3c3f","RedeemRequest":"0x7421d403cf73830acc41ebc9646c74f4a931f747ac275e32d82fc3a7c7a9aef","Manager":"0x7dbf4bc6ebf73952e77d60a7da8b1ec1523cfd3477ed99253403a3e0cab40d0","SimpleDecoderAndSanitizer":"0x19c38fa79f607d0935596802bcee103b88bc81dfa4b9f2d8bf0e398b19ec3c8","UsdtFixer":"0x19b362ba0e70d94185ebb986ea80039ec19ffca410f200f971fe5731ffd5a21","RedemptionRouter":"0x5a11a86291a4f64583bda5c9706535253d842bd5a391f6481bcce38f24351b2"},"contracts":{"Vault":"0x6a346bda4e723d3f4763513007d4b8ef0029f491ed0a1e0626db6d7f3af3c01","RedeemRequest":"0x66c5f84e5fc6c20545737cc187289adccacfeb9829da6fd4ddf6121b32573dc","VaultAllocator":"0x292503a0bff97ad8818481cca50f5f9298537d0e0e9dc6a7ac9bbb8a93f71a3","Manager":"0x2d7822d1616f5e0b556c0f7467eb968bd2acd0f2b2b8623adff96a51e5516db","SimpleDecoderAndSanitizer":"0x7b6f98311af8aa425278570e62abf523e6462eaa01a38c1feab9b2f416492e2","aum_oracle":"0x149298ade3e79ec6cbdac6cfad289c57504eaf54e590939136ed1ceca60c345","UsdtFixer":"0x7954afaca4c706f9f30658777e55b7f8e264c8974b8a2638d5ebb359280816","RedemptionRouter":"0x3de9c409d1e357e25778fb7a3e2e2393666956846a5c2caa607296fa8e76b5d"}} \ No newline at end of file diff --git a/my_scripts/deploy.ts b/my_scripts/deploy.ts index 070a26e7..1252c045 100644 --- a/my_scripts/deploy.ts +++ b/my_scripts/deploy.ts @@ -316,6 +316,38 @@ async function pause(strategy: UniversalStrategy) { await Deployer.executeTransactions([pauseCall], acc, provider, 'Pause'); } +async function deployUsdtFixer() { + const provider = config.provider; + const calls = await Deployer.prepareMultiDeployContracts([{ + contract_name: 'UsdtFixer', + package_name: VAULT_PACKAGE, + constructorData: [] + }], config, acc); + await Deployer.executeDeployCalls(calls, acc, provider); +} + +async function deployRedemptionRouter() { + const provider = config.provider; + // ! set strategy + const strategy = HyperLSTStrategies.find(u => u.name.includes('xtBTC'))!; + const calls = await Deployer.prepareMultiDeployContracts([{ + contract_name: 'RedemptionRouter', + package_name: VAULT_PACKAGE, + constructorData: [ + OWNER, + strategy.additionalInfo.vaultAddress.address, + strategy.additionalInfo.redeemRequestNFT.address, + // ! set to_asset + Global.getDefaultTokens().find(t => t.symbol === 'tBTC')?.address!, + "0x04270219d365d6b017231b52e92b3fb5d7c8378b05e9abc97724537a80e93b0f", // avnu exchange + OWNER, + "0", + uint256.bnToUint256(0) // min subscribe amount + ] + }], config, acc); + await Deployer.executeDeployCalls(calls, acc, provider); +} + async function unpause(strategy: UniversalStrategy) { const provider = config.provider; const cls = await provider.getClassAt(strategy.address.address.toString()); @@ -371,7 +403,7 @@ if (require.main === module) { // deployStrategy(); // deployAUMOracle("0x437ef1e7d0f100b2e070b7a65cafec0b2be31b0290776da8b4112f5473d8d9") - const strategy = HyperLSTStrategies.find(u => u.name.includes('xsBTC'))!; + const strategy = HyperLSTStrategies.find(u => u.name.includes('xSTRK'))!; // const vaultStrategy = new UniversalStrategy(config, pricer, strategy); const vaultStrategy = new UniversalLstMultiplierStrategy(config, pricer, strategy); const vaultContracts = { @@ -380,27 +412,30 @@ if (require.main === module) { vaultAllocator: strategy.additionalInfo.vaultAllocator, manager: strategy.additionalInfo.manager } + + // deployUsdtFixer(); + // deployRedemptionRouter(); async function setConfig() { - await upgrade('Vault', VAULT_PACKAGE, vaultContracts.vault.toString()); - await upgrade('VaultAllocator', VAULT_ALLOCATOR_PACKAGE, vaultContracts.vaultAllocator.toString()); - await upgrade('Manager', VAULT_ALLOCATOR_PACKAGE, vaultContracts.manager.toString()); - await upgrade('RedeemRequest', VAULT_PACKAGE, vaultContracts.redeemRequest.toString()); + // await upgrade('Vault', VAULT_PACKAGE, vaultContracts.vault.toString()); + // await upgrade('VaultAllocator', VAULT_ALLOCATOR_PACKAGE, vaultContracts.vaultAllocator.toString()); + // await upgrade('Manager', VAULT_ALLOCATOR_PACKAGE, vaultContracts.manager.toString()); + // await upgrade('RedeemRequest', VAULT_PACKAGE, vaultContracts.redeemRequest.toString()); // await configureSettings(vaultContracts); // await setManagerRoot(vaultStrategy, ContractAddr.from(RELAYER)); // await grantRole(vaultStrategy, hash.getSelectorFromName('ORACLE_ROLE'), strategy.additionalInfo.aumOracle.address); // await setMaxDelta(vaultStrategy, getMaxDelta(200, CommonSettings.vault.default_settings.report_delay * 6)); - // for (let i=0; i < UniversalStrategies.length; i++) { - // const u = UniversalStrategies[i]; - // const strategy = new UniversalStrategy(config, pricer, u); - // await setManagerRoot(strategy, ContractAddr.from(RELAYER)); + for (let i=0; i < HyperLSTStrategies.length; i++) { + const u = HyperLSTStrategies[i]; + const strategy = new UniversalLstMultiplierStrategy(config, pricer, u); + await setManagerRoot(strategy, ContractAddr.from(RELAYER)); // await setMaxDelta(strategy, getMaxDelta(200, CommonSettings.vault.default_settings.report_delay * 24)); // await grantRole(u, hash.getSelectorFromName('ORACLE_ROLE'), strategy.additionalInfo.aumOracle.address); // await setFeesConfig(strategy); // await pause(strategy); - // await unpause(strategy); - // } + // await unpause(strategy); + } // const netAPY = await vaultStrategy.netAPY(); // console.log(netAPY); @@ -415,7 +450,7 @@ if (require.main === module) { // asset: u.depositTokens[0].address.address // }))) // deploySanitizer(); - // upgrade('Vault', VAULT_PACKAGE, vaultContracts.vault.toString()); + // upgrade('RedemptionRouter', VAULT_PACKAGE, '0x6ea649f402898f69baf775c1afdd08522c071c640b9c4460192070ec2b96417'); // grantRole(vaultStrategy, hash.getSelectorFromName('ORACLE_ROLE'), '0x2edf4edbed3f839e7f07dcd913e92299898ff4cf0ba532f8c572c66c5b331b2') // setMaxDelta(vaultStrategy, getMaxDelta(15, CommonSettings.vault.default_settings.report_delay * 24)); } \ No newline at end of file diff --git a/my_scripts/package.json b/my_scripts/package.json index d34ea35d..7d4dacb0 100644 --- a/my_scripts/package.json +++ b/my_scripts/package.json @@ -20,7 +20,7 @@ "axios": "^1.12.2", "dotenv": "^17.2.1", "react": "^19.1.1", - "starknet": "8.5.3" + "starknet": "9.2.1" }, "devDependencies": { "@types/node": "^24.3.1", diff --git a/my_scripts/pnpm-lock.yaml b/my_scripts/pnpm-lock.yaml index 5da4d42d..6881b24e 100644 --- a/my_scripts/pnpm-lock.yaml +++ b/my_scripts/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^2.0.0 version: 2.0.1 '@strkfarm/sdk': - specifier: link:../../../Library/pnpm/global/5/node_modules/@strkfarm/sdk - version: link:../../../Library/pnpm/global/5/node_modules/@strkfarm/sdk + specifier: link:../../sdk-ts + version: link:../../sdk-ts axios: specifier: ^1.12.2 version: 1.12.2 @@ -30,8 +30,8 @@ importers: specifier: ^19.1.1 version: 19.2.0 starknet: - specifier: 8.5.3 - version: 8.5.3 + specifier: 9.2.1 + version: 9.2.1 devDependencies: '@types/node': specifier: ^24.3.1 @@ -42,6 +42,9 @@ importers: packages: + '@adraffy/ens-normalize@1.10.1': + resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==} + '@ericnordelo/strk-merkle-tree@1.0.0': resolution: {integrity: sha512-iKY8uZ84W6pOzUrq09QHb+jRU7LcB8b/h2kpmX1gEVVsGrQI84xqTjVQCyU2Pzl7h8loc3SL835yMUKdoXWjrQ==} @@ -259,6 +262,10 @@ packages: resolution: {integrity: sha512-UTMhXK9SeDhFJVrHeUJ5uZlI6ajXg10O6Ddocf9S6GjbSBVZsJo88HzKwXznNfGpMTRDyJkqMjNDPYgf0qFWnw==} engines: {node: ^14.21.3 || >=16} + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + '@noble/curves@2.0.1': resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} engines: {node: '>= 20.19.0'} @@ -267,6 +274,10 @@ packages: resolution: {integrity: sha512-YUULf0Uk4/mAA89w+k3+yUYh6NrEvxZa5T6SY3wlMvE2chHkxFUUIDI8/XW1QSC357iA5pSnqt7XEhvFOqmDyQ==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.0.1': resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} @@ -274,25 +285,56 @@ packages: '@scure/base@1.2.1': resolution: {integrity: sha512-DGmGtC8Tt63J5GfHgfl5CuAXh96VF/LD8K9Hr/Gv0J2lAoRGlPOMpqMpMbCTOoOJMZCk2Xt+DskdDyn6dEFdzQ==} + '@scure/base@1.2.6': + resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + + '@scure/bip32@1.7.0': + resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} + + '@scure/bip39@1.6.0': + resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + '@scure/starknet@1.1.0': resolution: {integrity: sha512-83g3M6Ix2qRsPN4wqLDqiRZ2GBNbjVWfboJE/9UjfG+MHr6oDSu/CWgy8hsBSJejr09DkkL+l0Ze4KVrlCIdtQ==} + '@starknet-io/get-starknet-wallet-standard@5.0.0': + resolution: {integrity: sha512-isDNGDlp16W24HE4IuweYXLDRZN0JbsDnazAieeKXE87Mn+jqhsjgTsMxcwWTjX7v906Bjz39FiDjGUddnr36g==} + + '@starknet-io/types-js@0.10.0': + resolution: {integrity: sha512-7ALSydz6pq3YIOpq5a7OkkxqwJciMc9Nlph0OGjhcC3xX0xH30XgizmziLyYVN10oO9+BJk8M9KbJjpzdbtRSw==} + '@starknet-io/types-js@0.7.10': resolution: {integrity: sha512-1VtCqX4AHWJlRRSYGSn+4X1mqolI1Tdq62IwzoU2vUuEE72S1OlEeGhpvd6XsdqXcfHmVzYfj8k1XtKBQqwo9w==} - '@starknet-io/types-js@0.8.4': - resolution: {integrity: sha512-0RZ3TZHcLsUTQaq1JhDSCM8chnzO4/XNsSCozwDET64JK5bjFDIf2ZUkta+tl5Nlbf4usoU7uZiDI/Q57kt2SQ==} - '@starknet-io/types-js@0.9.2': resolution: {integrity: sha512-vWOc0FVSn+RmabozIEWcEny1I73nDGTvOrLYJsR1x7LGA3AZmqt4i/aW69o/3i2NN5CVP8Ok6G1ayRQJKye3Wg==} '@types/node@24.9.1': resolution: {integrity: sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==} + '@wallet-standard/base@1.1.0': + resolution: {integrity: sha512-DJDQhjKmSNVLKWItoKThJS+CsJQjR9AOBOirBVT1F9YpRyC9oYHE+ZnSf8y8bxUphtKqdQMPVQ2mHohYdRvDVQ==} + engines: {node: '>=16'} + + '@wallet-standard/features@1.1.0': + resolution: {integrity: sha512-hiEivWNztx73s+7iLxsuD1sOJ28xtRix58W7Xnz4XzzA/pF0+aicnWgjOdA10doVDEDZdUuZCIIqG96SFNlDUg==} + engines: {node: '>=16'} + abi-wan-kanabi@2.2.4: resolution: {integrity: sha512-0aA81FScmJCPX+8UvkXLki3X1+yPQuWxEkqXBVKltgPAK79J+NB+Lp5DouMXa7L6f+zcRlIA/6XO7BN/q9fnvg==} hasBin: true + abitype@1.2.3: + resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -390,6 +432,9 @@ packages: engines: {node: '>=4'} hasBin: true + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + fetch-cookie@3.0.1: resolution: {integrity: sha512-ZGXe8Y5Z/1FWqQ9q/CrJhkUD73DyBU9VF0hBQmEO/wPHe4A9PKTjplFDLeFX8aOsYypZUcX5Ji/eByn3VCVO3Q==} @@ -504,6 +549,14 @@ packages: encoding: optional: true + ox@0.4.4: + resolution: {integrity: sha512-oJPEeCDs9iNiPs6J0rTx+Y0KGeCGyCAA3zo94yZhm8G5WpOxrwUtn2Ie/Y8IyARSqqY/j9JTKA3Fc1xs1DvFnw==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + pako@2.1.0: resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} @@ -543,8 +596,8 @@ packages: starknet@6.24.1: resolution: {integrity: sha512-g7tiCt73berhcNi41otlN3T3kxZnIvZhMi8WdC21Y6GC6zoQgbI2z1t7JAZF9c4xZiomlanwVnurcpyfEdyMpg==} - starknet@8.5.3: - resolution: {integrity: sha512-E0Z4Jk3W0hS8FgMFjS4mPown6zrFfR7rUwGBlskgUc4X7ZqEfOO15s90kXIFI2letcM3bI6APRytYfBMhBKoPQ==} + starknet@9.2.1: + resolution: {integrity: sha512-bFJY2sMZ9tsLBhPCm719MWjoz+doabXIwPX/xtW56EHwAJMRAS6mICF6H2dCwOQHJmCMKpOSFBwW0SaiHzcioQ==} engines: {node: '>=22'} string-width@4.2.3: @@ -611,6 +664,8 @@ packages: snapshots: + '@adraffy/ens-normalize@1.10.1': {} + '@ericnordelo/strk-merkle-tree@1.0.0': dependencies: '@ethersproject/abi': 5.8.0 @@ -826,24 +881,53 @@ snapshots: dependencies: '@noble/hashes': 1.6.0 + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + '@noble/curves@2.0.1': dependencies: '@noble/hashes': 2.0.1 '@noble/hashes@1.6.0': {} + '@noble/hashes@1.8.0': {} + '@noble/hashes@2.0.1': {} '@scure/base@1.2.1': {} + '@scure/base@1.2.6': {} + + '@scure/bip32@1.7.0': + dependencies: + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@scure/bip39@1.6.0': + dependencies: + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + '@scure/starknet@1.1.0': dependencies: '@noble/curves': 1.7.0 '@noble/hashes': 1.6.0 - '@starknet-io/types-js@0.7.10': {} + '@starknet-io/get-starknet-wallet-standard@5.0.0': + dependencies: + '@starknet-io/types-js': 0.7.10 + '@wallet-standard/base': 1.1.0 + '@wallet-standard/features': 1.1.0 + ox: 0.4.4 + transitivePeerDependencies: + - typescript + - zod - '@starknet-io/types-js@0.8.4': {} + '@starknet-io/types-js@0.10.0': {} + + '@starknet-io/types-js@0.7.10': {} '@starknet-io/types-js@0.9.2': {} @@ -851,6 +935,12 @@ snapshots: dependencies: undici-types: 7.16.0 + '@wallet-standard/base@1.1.0': {} + + '@wallet-standard/features@1.1.0': + dependencies: + '@wallet-standard/base': 1.1.0 + abi-wan-kanabi@2.2.4: dependencies: ansicolors: 0.3.2 @@ -858,6 +948,8 @@ snapshots: fs-extra: 10.1.0 yargs: 17.7.2 + abitype@1.2.3: {} + ansi-regex@5.0.1: {} ansi-styles@4.3.0: @@ -978,6 +1070,8 @@ snapshots: esprima@4.0.1: {} + eventemitter3@5.0.1: {} + fetch-cookie@3.0.1: dependencies: set-cookie-parser: 2.7.1 @@ -1090,6 +1184,18 @@ snapshots: dependencies: whatwg-url: 5.0.0 + ox@0.4.4: + dependencies: + '@adraffy/ens-normalize': 1.10.1 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3 + eventemitter3: 5.0.1 + transitivePeerDependencies: + - zod + pako@2.1.0: {} proxy-from-env@1.1.0: {} @@ -1132,18 +1238,22 @@ snapshots: transitivePeerDependencies: - encoding - starknet@8.5.3: + starknet@9.2.1: dependencies: '@noble/curves': 1.7.0 '@noble/hashes': 1.6.0 '@scure/base': 1.2.1 '@scure/starknet': 1.1.0 - '@starknet-io/starknet-types-08': '@starknet-io/types-js@0.8.4' + '@starknet-io/get-starknet-wallet-standard': 5.0.0 + '@starknet-io/starknet-types-010': '@starknet-io/types-js@0.10.0' '@starknet-io/starknet-types-09': '@starknet-io/types-js@0.9.2' abi-wan-kanabi: 2.2.4 lossless-json: 4.3.0 pako: 2.1.0 ts-mixer: 6.0.4 + transitivePeerDependencies: + - typescript + - zod string-width@4.2.3: dependencies: diff --git a/package.json b/package.json new file mode 100644 index 00000000..a16cb056 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "@strkfarm/sdk": "link:../sdk-ts" + } +} diff --git a/packages/vault/Scarb.toml b/packages/vault/Scarb.toml index 17f37870..b2cd858a 100644 --- a/packages/vault/Scarb.toml +++ b/packages/vault/Scarb.toml @@ -34,7 +34,7 @@ snforge_std.workspace = true [lib] [[target.starknet-contract]] -build-external-contracts = ["vault_allocator::vault_allocator::vault_allocator::VaultAllocator", "vault_allocator::mocks::erc20::Erc20Mock", "vault_allocator::mocks::counter::Counter"] +build-external-contracts = ["vault_allocator::vault_allocator::vault_allocator::VaultAllocator", "vault_allocator::mocks::erc20::Erc20Mock", "vault_allocator::mocks::counter::Counter", "vault_allocator::mocks::mock_avnu_exchange::MockAvnuExchange"] allowed-libfuncs-list.name = "experimental" sierra = true casm = true diff --git a/packages/vault/src/lib.cairo b/packages/vault/src/lib.cairo index 12aa3603..c20aab18 100644 --- a/packages/vault/src/lib.cairo +++ b/packages/vault/src/lib.cairo @@ -26,11 +26,18 @@ pub mod redeem_request { pub mod redeem_request; } +pub mod redemption_router { + pub mod errors; + pub mod interface; + pub mod redemption_router; +} + #[cfg(test)] pub mod test { pub mod utils; pub mod units { pub mod redeem_request; pub mod vault; + pub mod redemption_router; } } diff --git a/packages/vault/src/redemption_router/INTEGRATION.md b/packages/vault/src/redemption_router/INTEGRATION.md new file mode 100644 index 00000000..2e79c7a8 --- /dev/null +++ b/packages/vault/src/redemption_router/INTEGRATION.md @@ -0,0 +1,137 @@ +# Redemption Router Integration Guide + +This guide explains how to integrate the Redemption Router contract for both backend services (relayers/indexers) and frontend applications. + +## Table of Contents + +- [Overview](#overview) +- [Backend Integration](#backend-integration) + - [Indexing Pending Subscriptions](#indexing-pending-subscriptions) + - [Checking Epoch Settlement Status](#checking-epoch-settlement-status) + - [Executing Swaps](#executing-swaps) + - [Processing Claims](#processing-claims) +- [Frontend Integration](#frontend-integration) + - [Request Redeem and Subscribe (Multicall)](#request-redeem-and-subscribe-multicall) + - [Checking Subscription Status](#checking-subscription-status) + - [Unsubscribing](#unsubscribing) + - [Claiming Redeemed Assets](#claiming-redeemed-assets) + +## Overview + +The Redemption Router allows users to: +1. **Subscribe** their redemption NFTs to receive assets in a different token (`to_asset`) instead of the vault's native asset +2. **Claim** their swapped assets once epochs are settled and swaps are executed +3. **Unsubscribe** if they want to opt out before settlement + +Backend services (relayers) are responsible for: +- Monitoring pending subscriptions +- Executing swaps when epochs are settled +- Managing the swap pool + +## Reporting +For simplicity, its recommended to call report function on vault contract via this contract. Else, its important to set the epoch offset factor for each epoch before calling swap. + +Note: Until audit of RR, our (Troves team) backend shall follow manually setting epoch offset factor for each epoch before calling swap. This is to avoid transfering oracle permission to RR for vaults already in production. + +## Backend Integration + +### Indexing Pending Subscriptions + +To track pending subscriptions, you need to index the `Subscribed` event emitted by the Redemption Router contract. + +#### You could use apibara to index events. +``` + +#### Decoding Subscribed Event + +The `Subscribed` event has the following structure: +```cairo +Subscribed { + new_nft_id: u256, + old_nft_id: u256, + receiver: ContractAddress, +} +``` + +Store this information in your database to track pending subscriptions. + +Additionally, more events to track: +1. Claimed +```cairo +Claimed { + new_nft_id: u256, + old_nft_id: u256, + receivable: u256, + swap_id: u256, +} +``` +2. Unsubscribed +```cairo +Unsubscribed { + new_nft_id: u256, + old_nft_id: u256, + owner: ContractAddress, + is_old_nft_returned: bool, + is_original_assets_returned: bool, + original_assets_returned: u256, +} +``` + +### Checking Epoch Settlement Status + +Before executing swaps, you need to check where required epochs are already handled by the vault. An epoch is considered "handled" when the vault has handled it (i.e., `epoch < vault.handled_epoch_len()`). + +Get above info by calling vault contract. + +#### Key Functions to Monitor + +- `vault.handled_epoch_len()` - Returns the number of handled epochs +- `router.last_settled_epoch()` - Returns the highest fully settled epoch in router (i.e. assets swapped and ready for claims) +- `router.epoch_settled_amounts(epoch)` - Returns how much of an epoch has been settled (i.e. swapped from asset) +- `router.epoch_offset_factor(epoch)` - Returns the offset factor for an epoch (defaults to WAD) (i.e. nft shares to asset conversion factor. precisely, final assets / shares settled for which can vary in a epoch due to epoch level vault losses) + + +### Swapping +Read the balance of the redemption router contract of the from asset (i.e. vault asset). You can have some min check, but if enough balance is available, you can proceed to swap. Ensure you add sufficient min amoutn out checks. + +Call `router.swap(routes: Array, from_amount: u256, min_amount_out: u256)` to swap the assets. +Can get routes from Avnu. + +#### Monitoring Swap Pool + +The router maintains a swap pool that users claim from. Monitor: +- `router.swap_id()` - Current swap ID (next swap will use this) +- `router.unsettled_swap_id()` - First swap ID that still has remaining assets +- `router.swap_info(swap_id)` - Returns `(from_remaining, to_remaining)` for a swap + +### Processing Claims + +Users can claim their assets once their epoch is settled. To settle NFTs, you use the Claimed event to check events which are still pending. Query them, order them by epoch (asc). If epoch is settled, you can claim the assets. + +Call `router.claim(nft_id)` to claim the assets. + +## Frontend Integration + +### Request Redeem and Subscribe (Multicall) + +To provide a seamless UX, combine `vault.request_redeem()` and `router.subscribe()` in a single multicall transaction. + +1. Read next NFT ID to mint (i.e. `redeem_request.id_len()`) +2. Create a redeem request on vault (i.e. `vault.request_redeem(shares, receiver, user_address)`) +3. Approve the NFT to RR (i.e. `redeem_request.approve(router_address, nft_id)`) +4. Subscribe to the router (i.e. `router.subscribe(nft_id, receiver)`) + +Put all above 3 steps into a single starknet multicall transaction. + +In rare cases, the step 1 might cause race condition, in that case, simply retrying the transaction should work. + +## Unsubscribing +In case the router couldnt settle the funds in to_asset or its taking time, users might want to unsubscribe. + +Users can unsubscribe in two ways: +1. **`unsubscribe_for_nft`** - Get back the original redemption NFT (if epoch not settled) +2. **`unsubscribe_for_underlying`** - Get back the underlying assets proportional to their share (only possible if router assets are not fully settled for the epoch. Even partial swap shall prevent this, in such a case, users must wait for their funds to be fully swapped). + +### How to decide which function to call? +- Call `vault.due_assets_from_id(old_nft_id)` to get the due assets for the NFT. If returns non-zero, call `unsubscribe_for_nft`. If the call fails or returns 0, call `unsubscribe_for_underlying`. +- The `unsubscribe_for_underlying` shall only work if epoch is not settled. Call `router.epoch_settled_amounts(epoch)`, if returns 0, you can proceed. Else better to inform users to wait as swapping has already started and is in progress. \ No newline at end of file diff --git a/packages/vault/src/redemption_router/errors.cairo b/packages/vault/src/redemption_router/errors.cairo new file mode 100644 index 00000000..37d77a8e --- /dev/null +++ b/packages/vault/src/redemption_router/errors.cairo @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 Starknet Vault Kit +// Licensed under the MIT License. See LICENSE file for details. + +pub mod Errors { + pub fn zero_address() { + panic!("Zero address"); + } + + pub fn invalid_swap_id() { + panic!("Invalid swap id"); + } + + pub fn swap_not_settled() { + panic!("Swap not settled"); + } + + pub fn insufficient_from_amount() { + panic!("Insufficient from amount"); + } + + pub fn invalid_nft_id() { + panic!("Invalid NFT id"); + } + + pub fn invalid_old_nft_id() { + panic!("Invalid old NFT id"); + } + + pub fn claim_not_allowed() { + panic!("Claim not allowed"); + } + + pub fn swap_failed() { + panic!("Swap failed"); + } + + pub fn nft_already_claimed() { + panic!("NFT already claimed"); + } + + pub fn nft_already_withdrawn() { + panic!("NFT already withdrawn"); + } + + pub fn insufficient_balance_for_withdrawal() { + panic!("Insufficient balance for withdrawal"); + } + + pub fn cannot_unsubscribe_partial_swap() { + panic!("Cannot unsubscribe: swaps have partially consumed assets"); + } + + pub fn invalid_swap_from_amount() { + panic!("Invalid swap from amount"); + } + + pub fn too_small_subscribe_amount() { + panic!("Too small subscribe amount"); + } + + pub fn not_owner() { + panic!("Not owner"); + } + + pub fn invalid_fee_amount() { + panic!("Invalid integrator fee amount"); + } + + pub fn epoch_already_handled() { + panic!("Epoch already handled"); + } +} + + diff --git a/packages/vault/src/redemption_router/interface.cairo b/packages/vault/src/redemption_router/interface.cairo new file mode 100644 index 00000000..e50fa218 --- /dev/null +++ b/packages/vault/src/redemption_router/interface.cairo @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 Starknet Vault Kit +// Licensed under the MIT License. See LICENSE file for details. + +use starknet::ContractAddress; +use vault_allocator::decoders_and_sanitizers::decoder_custom_types::Route; + +#[derive(Drop, Copy, starknet::Store, Serde, starknet::Event)] +pub struct RequestInfo { + pub old_nft_id: u256, + pub is_claimed: bool, + pub epoch: u256, + pub nominal: u256, + pub due_amount_approximate: u256, + pub unsubscribed: bool, // if true, the original NFT has been unsubscribed +} + +#[starknet::interface] +pub trait IRedemptionRouter { + fn subscribe(ref self: TContractState, nft_id: u256, receiver: ContractAddress) -> u256; + fn redeem_and_subscribe(ref self: TContractState, shares: u256, receiver: ContractAddress) -> u256; + fn swap( + ref self: TContractState, + routes: Array, + from_amount: u256, + min_amount_out: u256, + ) -> u256; + fn claim(ref self: TContractState, nft_id: u256) -> u256; + + // Transfer the original NFT to receiver (contract is the owner itself) + // Reverts if epoch is fully or partially settled in swap + fn unsubscribe_for_nft(ref self: TContractState, nft_id: u256, receiver: ContractAddress); + + // Return underlying assets proportional to user's share + // Checks if epoch < handled_epoch_len, computes assets based on current offset factor + // Reverts if epoch is fully or partially settled in swap + fn unsubscribe_for_underlying(ref self: TContractState, nft_id: u256, receiver: ContractAddress); + + // Setters + fn set_integrator_fee_recipient(ref self: TContractState, recipient: ContractAddress); + fn set_integrator_fee_amount_bps(ref self: TContractState, fee_bps: u128); + fn set_epoch_offset(ref self: TContractState, epoch: u256, offset_factor: u256); + fn get_epoch_offset(self: @TContractState, epoch: u256) -> u256; + fn report(ref self: TContractState, new_aum: u256); + fn set_min_subscribe_amount(ref self: TContractState, min_subscribe_amount: u256); + + // View functions + fn vault(self: @TContractState) -> ContractAddress; + fn redeem_request(self: @TContractState) -> ContractAddress; + fn to_asset(self: @TContractState) -> ContractAddress; + fn avnu_exchange(self: @TContractState) -> ContractAddress; + fn integrator_fee_recipient(self: @TContractState) -> ContractAddress; + fn integrator_fee_amount_bps(self: @TContractState) -> u128; + fn swap_id(self: @TContractState) -> u256; + fn unsettled_swap_id(self: @TContractState) -> u256; + fn new_nft_request_info(self: @TContractState, new_nft_id: u256) -> RequestInfo; + fn swap_info(self: @TContractState, swap_id: u256) -> (u256, u256); // Returns (from_amount, to_amount) + fn last_nft_id(self: @TContractState) -> u256; + fn expected_receivable(self: @TContractState, nft_id: u256) -> u256; + fn last_settled_epoch(self: @TContractState) -> u256; + fn epoch_settled_amounts(self: @TContractState, epoch: u256) -> u256; + + // Sync settled epochs state by checking which epochs are fully settled + // max_epochs_to_check limits how many epochs to check to prevent excessive gas usage + // - swap function also call this, but incase it goes of out gas, caller can call this to sync settled epochs + fn sync_settled_epochs(ref self: TContractState, max_epochs_to_check: u256); + + fn pause(ref self: TContractState); + fn unpause(ref self: TContractState); +} + + diff --git a/packages/vault/src/redemption_router/redemption_router.cairo b/packages/vault/src/redemption_router/redemption_router.cairo new file mode 100644 index 00000000..260ef783 --- /dev/null +++ b/packages/vault/src/redemption_router/redemption_router.cairo @@ -0,0 +1,1077 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 Starknet Vault Kit +// Licensed under the MIT License. See LICENSE file for details. + +// Helps users redeem their NFTs in a different asset than the one configured in the vault +#[starknet::contract] +pub mod RedemptionRouter { + // Role constants + pub const OWNER_ROLE: felt252 = selector!("OWNER_ROLE"); + pub const PAUSER_ROLE: felt252 = selector!("PAUSER_ROLE"); + pub const RELAYER_ROLE: felt252 = selector!("RELAYER_ROLE"); + + // Mathematical constants + pub const WAD: u256 = 1_000_000_000_000_000_000; // 1e18 + + use core::num::traits::Zero; + use openzeppelin::access::accesscontrol::AccessControlComponent; + use openzeppelin::interfaces::erc20::{ERC20ABIDispatcher, ERC20ABIDispatcherTrait}; + use openzeppelin::interfaces::erc4626::{IERC4626Dispatcher, IERC4626DispatcherTrait}; + use openzeppelin::interfaces::erc721::{ERC721ABIDispatcher, ERC721ABIDispatcherTrait}; + use openzeppelin::interfaces::upgrades::IUpgradeable; + use openzeppelin::introspection::src5::SRC5Component; + use openzeppelin::security::pausable::PausableComponent; + use openzeppelin::token::erc721::{ERC721Component, ERC721HooksEmptyImpl}; + use openzeppelin::upgrades::upgradeable::UpgradeableComponent; + use openzeppelin::utils::math; + use starknet::storage::{ + Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, + StoragePointerWriteAccess, + }; + use starknet::{ContractAddress, get_caller_address, get_contract_address}; + use vault::redemption_router::errors::Errors; + use vault::redemption_router::interface::{IRedemptionRouter, RequestInfo}; + use vault::redeem_request::interface::{IRedeemRequestDispatcher, IRedeemRequestDispatcherTrait}; + use vault::vault::interface::{IVaultDispatcher, IVaultDispatcherTrait}; + use vault_allocator::decoders_and_sanitizers::decoder_custom_types::Route; + use vault_allocator::integration_interfaces::avnu::IAvnuExchangeDispatcher; + use vault_allocator::integration_interfaces::avnu::IAvnuExchangeDispatcherTrait; + + // --- OpenZeppelin Component Integrations --- + component!(path: SRC5Component, storage: src5, event: SRC5Event); + component!(path: ERC721Component, storage: erc721, event: ERC721Event); + component!(path: AccessControlComponent, storage: access_control, event: AccessControlEvent); + component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent); + component!(path: PausableComponent, storage: pausable, event: PausableEvent); + + #[abi(embed_v0)] + impl ERC721MixinImpl = ERC721Component::ERC721MixinImpl; + impl ERC721InternalImpl = ERC721Component::InternalImpl; + impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl; + impl AccessControlInternalImpl = AccessControlComponent::InternalImpl; + impl PausableInternalImpl = PausableComponent::InternalImpl; + #[abi(embed_v0)] + impl AccessControlImpl = AccessControlComponent::AccessControlImpl; + #[abi(embed_v0)] + impl PausableImpl = PausableComponent::PausableImpl; + + #[storage] + struct Storage { + #[substorage(v0)] + src5: SRC5Component::Storage, + #[substorage(v0)] + erc721: ERC721Component::Storage, + #[substorage(v0)] + access_control: AccessControlComponent::Storage, + #[substorage(v0)] + upgradeable: UpgradeableComponent::Storage, + #[substorage(v0)] + pausable: PausableComponent::Storage, + + // constants for the redemption router + vault: ContractAddress, + redeem_request: ContractAddress, + to_asset: ContractAddress, + avnu_exchange: ContractAddress, + + // modifiable parameters + integrator_fee_recipient: ContractAddress, + integrator_fee_amount_bps: u128, + + // state variables + swap_id: u256, // sequently updated id for each swap + unsettled_swap_id: u256, + nft_id_counter: u256, // Counter for new NFT IDs + new_nft_request_map: Map, + + // swap_id -> (from_remaining, to_remaining) + // - created during a swap and reduced when claims are made + swap_info: Map, + + // Epoch offset tracking (in case, an epoch incurs loss, the output amount is lower than + // expected value computed during subscribe time) + // this factor represents that relative loss in WAD + epoch_offset_factor: Map, // epoch -> offset_factor (defaults to WAD) + epoch_redeem_assets: Map, // epoch -> snapshot of redeem_assets at subscribe time + epoch_redeem_nominal: Map, // epoch -> snapshot of redeem_nominal at subscribe time + epoch_wise_nominals: Map, // epoch -> from_amount (nominal amount subscribed for this epoch) + epoch_settled_amounts: Map, // epoch -> actual settled (i.e. swapped) from_amount + last_settled_epoch: u256, // Highest epoch number that has been fully settled (may not be sequential if some epochs have no subscriptions) + + min_subscribe_amount: u256, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + #[flat] + ERC721Event: ERC721Component::Event, + #[flat] + SRC5Event: SRC5Component::Event, + #[flat] + AccessControlEvent: AccessControlComponent::Event, + #[flat] + UpgradeableEvent: UpgradeableComponent::Event, + #[flat] + PausableEvent: PausableComponent::Event, + Subscribed: Subscribed, + Swapped: Swapped, + Claimed: Claimed, + RequestInfo: RequestInfo, + Unsubscribed: Unsubscribed, + IntegratorFeeRecipientSet: IntegratorFeeRecipientSet, + IntegratorFeeAmountBpsSet: IntegratorFeeAmountBpsSet, + MinSubscribeAmountSet: MinSubscribeAmountSet, + } + + #[derive(Drop, starknet::Event)] + pub struct Subscribed { + #[key] + pub new_nft_id: u256, + #[key] + pub old_nft_id: u256, + #[key] + pub receiver: ContractAddress, + } + + #[derive(Drop, starknet::Event)] + pub struct Unsubscribed { + #[key] + pub new_nft_id: u256, + #[key] + pub old_nft_id: u256, + #[key] + pub owner: ContractAddress, + pub is_old_nft_returned: bool, // true if old NFT returned as is + pub is_original_assets_returned: bool, // true if original assets returned proportional to user's share + pub original_assets_returned: u256, // original assets returned proportional to user's share + } + + #[derive(Drop, starknet::Event)] + pub struct Swapped { + #[key] + pub swap_id: u256, + pub from_amount: u256, + pub to_amount: u256, + pub last_settled_epoch: u256, + } + + #[derive(Drop, starknet::Event)] + pub struct Claimed { + #[key] + pub new_nft_id: u256, + #[key] + pub old_nft_id: u256, + #[key] + pub swap_id: u256, + pub receivable: u256, + } + + #[derive(Drop, starknet::Event)] + pub struct IntegratorFeeRecipientSet { + #[key] + pub recipient: ContractAddress, + } + + #[derive(Drop, starknet::Event)] + pub struct IntegratorFeeAmountBpsSet { + pub fee_bps: u128, + } + + #[derive(Drop, starknet::Event)] + pub struct MinSubscribeAmountSet { + pub min_subscribe_amount: u256, + } + + #[constructor] + fn constructor( + ref self: ContractState, + owner: ContractAddress, + vault: ContractAddress, + redeem_request: ContractAddress, + to_asset: ContractAddress, + avnu_exchange: ContractAddress, + integrator_fee_recipient: ContractAddress, + integrator_fee_amount_bps: u128, + min_subscribe_amount: u256, + ) { + // Non-zero checks + if (vault.is_zero()) { + Errors::zero_address(); + } + if (redeem_request.is_zero()) { + Errors::zero_address(); + } + if (to_asset.is_zero()) { + Errors::zero_address(); + } + if (avnu_exchange.is_zero()) { + Errors::zero_address(); + } + if (integrator_fee_recipient.is_zero()) { + Errors::zero_address(); + } + + // Initialize components + self.erc721.initializer("RedemptionRouter", "RR", "none"); + self.access_control.initializer(); + + // Set up role hierarchy - OWNER_ROLE is admin for all roles + self.access_control.set_role_admin(OWNER_ROLE, OWNER_ROLE); + self.access_control.set_role_admin(PAUSER_ROLE, OWNER_ROLE); + self.access_control.set_role_admin(RELAYER_ROLE, OWNER_ROLE); + // Initialize NFT counter + self.nft_id_counter.write(0); + // Grant owner role to owner + self.access_control._grant_role(OWNER_ROLE, owner); + // Store addresses + self.vault.write(vault); + self.redeem_request.write(redeem_request); + self.to_asset.write(to_asset); + self.avnu_exchange.write(avnu_exchange); + self.integrator_fee_recipient.write(integrator_fee_recipient); + self.integrator_fee_amount_bps.write(integrator_fee_amount_bps); + // Initialize swap counters (starting from 1) + self.swap_id.write(1); + self.unsettled_swap_id.write(1); + // Read the latest handled epoch from the vault + let vault_dispatcher = IVaultDispatcher { contract_address: self.vault.read() }; + let latest_handled_epoch = vault_dispatcher.handled_epoch_len(); + if (latest_handled_epoch > 0) { + self.last_settled_epoch.write(latest_handled_epoch - 1); + } + self.min_subscribe_amount.write(min_subscribe_amount); + } + + // Internal implementation for helper functions + #[generate_trait] + impl RedemptionRouterInternalImpl of RedemptionRouterInternalTrait { + fn _transfer_original_nft(self: @ContractState, nft_id: u256, from_address: ContractAddress, to_address: ContractAddress) { + let redeem_request_dispatcher = ERC721ABIDispatcher { + contract_address: self.redeem_request.read(), + }; + redeem_request_dispatcher.transfer_from(from_address, to_address, nft_id); + } + + // Get effective offset factor for an epoch (defaults to WAD if 0) + fn _get_effective_offset_factor(self: @ContractState, epoch: u256) -> u256 { + let offset_factor = self.epoch_offset_factor.read(epoch); + if (offset_factor == 0) { WAD } else { offset_factor } + } + + // Calculate expected settled amount for an epoch + fn _calculate_expected_settled(self: @ContractState, epoch: u256) -> u256 { + let epoch_nominal = self.epoch_wise_nominals.read(epoch); + if (epoch_nominal == 0) { + return 0; + } + let effective_offset = self._get_effective_offset_factor(epoch); + math::u256_mul_div( + epoch_nominal, + effective_offset, + WAD, + math::Rounding::Floor + ) + } + + // Check if an epoch is fully settled + fn _is_epoch_settled(self: @ContractState, epoch: u256) -> bool { + let expected_settled = self._calculate_expected_settled(epoch); + if (expected_settled == 0) { + return false; + } + let settled_amount = self.epoch_settled_amounts.read(epoch); + settled_amount >= expected_settled + } + + // Calculate adjusted due amount with epoch offset factor + fn _calculate_adjusted_due_amount(self: @ContractState, stored_due_amount: u256, epoch: u256) -> u256 { + let effective_offset = self._get_effective_offset_factor(epoch); + math::u256_mul_div( + stored_due_amount, + effective_offset, + WAD, + math::Rounding::Floor + ) + } + + // Validate request info (not claimed, not unsubscribed, valid) + fn _validate_request_info(self: @ContractState, request_info: RequestInfo) { + if (request_info.is_claimed) { + Errors::nft_already_claimed(); + } + if (request_info.unsubscribed) { + Errors::nft_already_withdrawn(); + } + if (request_info.due_amount_approximate == 0) { + Errors::invalid_nft_id(); + } + } + + // Validate epoch exists + fn _validate_epoch(self: @ContractState, epoch: u256) { + let epoch_nominal = self.epoch_redeem_nominal.read(epoch); + if (epoch_nominal == 0) { + Errors::invalid_nft_id(); + } + } + + fn _update_request_info(ref self: ContractState, nft_id: u256, request_info: RequestInfo) { + self.new_nft_request_map.write(nft_id, request_info); + self.emit(request_info); + } + + // Snapshot epoch data if not already stored + fn _snapshot_epoch_data(ref self: ContractState, epoch: u256, vault_dispatcher: IVaultDispatcher) { + let redeem_assets = vault_dispatcher.redeem_assets(epoch); + let redeem_nominal = vault_dispatcher.redeem_nominal(epoch); + + self.epoch_redeem_assets.write(epoch, redeem_assets); + self.epoch_redeem_nominal.write(epoch, redeem_nominal); + + // Initialize epoch_offset_factor to WAD if not set + if (self.epoch_offset_factor.read(epoch) == 0) { + self.epoch_offset_factor.write(epoch, WAD); + } + } + + // Internal subscription logic when NFT is already owned by this contract + fn _subscribe_internal( + ref self: ContractState, + nft_id: u256, + receiver: ContractAddress, + vault_dispatcher: IVaultDispatcher, + ) -> u256 { + // Read epoch and nominal from NFT + let redeem_request_interface = IRedeemRequestDispatcher { + contract_address: self.redeem_request.read(), + }; + let redeem_request_info = redeem_request_interface.id_to_info(nft_id); + let epoch = redeem_request_info.epoch; + let handled_epoch_len = vault_dispatcher.handled_epoch_len(); + if (epoch < handled_epoch_len) { + Errors::epoch_already_handled(); + } + + // Get due_amount from vault + let due_amount = vault_dispatcher.due_assets_from_id(nft_id); + if (due_amount == 0) { + // nothing to settle for any NFT with due_amount == 0 + Errors::invalid_nft_id(); + } + + // avoid processing very small subscriptions to save gas + if (due_amount < self.min_subscribe_amount.read()) { + Errors::too_small_subscribe_amount(); + } + + // Snapshot epoch data (required for fairly distributing redeemed assets to subscribers) + self._snapshot_epoch_data(epoch, vault_dispatcher); + + // Note: NFT is already owned by this contract, so no transfer needed + + // Mint new NFT to receiver + let new_nft_id = self.nft_id_counter.read(); + self.erc721.mint(receiver, new_nft_id); + self.nft_id_counter.write(new_nft_id + 1); + + // Store mapping with epoch and due_amount_approximate + let request_info = RequestInfo { + old_nft_id: nft_id, + is_claimed: false, + epoch, + nominal: redeem_request_info.nominal, + due_amount_approximate: due_amount, + unsubscribed: false, + }; + self._update_request_info(new_nft_id, request_info); + + // Update epoch_wise_nominals (accumulate nominal for this epoch) + // Use to compute how much of the epoch is settled + let current_epoch_amount = self.epoch_wise_nominals.read(epoch); + self.epoch_wise_nominals.write(epoch, current_epoch_amount + redeem_request_info.nominal); + + // Emit event + self.emit(Subscribed { new_nft_id, old_nft_id: nft_id, receiver }); + + new_nft_id + } + + // Process swap pool iteration to calculate receivable (read-only) + fn _calculate_receivable_from_pools( + self: @ContractState, + mut remaining_due: u256 + ) -> u256 { + let mut pool_id = self.unsettled_swap_id.read(); + let end_pool = self.swap_id.read(); + let mut total_to: u256 = 0; + + while (remaining_due > 0 && pool_id < end_pool) { + let (from_remaining, to_remaining) = self.swap_info.read(pool_id); + + if (from_remaining == 0) { + pool_id = pool_id + 1; + continue; + } + + let take_from = if (remaining_due > from_remaining) { from_remaining } else { remaining_due }; + let take_to = math::u256_mul_div(take_from, to_remaining, from_remaining, math::Rounding::Floor); + + remaining_due = remaining_due - take_from; + total_to = total_to + take_to; + pool_id = pool_id + 1; + } + + total_to + } + + // Process swap pool iteration and update state (write) + fn _process_swap_pools_for_claim( + ref self: ContractState, + mut remaining_due: u256 + ) -> u256 { + let mut pool_id = self.unsettled_swap_id.read(); + let end_pool = self.swap_id.read(); + let mut total_to: u256 = 0; + + while (remaining_due > 0 && pool_id < end_pool) { + let (from_remaining, to_remaining) = self.swap_info.read(pool_id); + + if (from_remaining == 0) { + // if pool is settled, advance unsettled_swap_id + if (self.unsettled_swap_id.read() == pool_id) { + self.unsettled_swap_id.write(pool_id + 1); + } + pool_id = pool_id + 1; + continue; + } + + let take_from = if (remaining_due > from_remaining) { from_remaining } else { remaining_due }; + let take_to = math::u256_mul_div(take_from, to_remaining, from_remaining, math::Rounding::Floor); + + let new_from = from_remaining - take_from; + let new_to = to_remaining - take_to; + self.swap_info.write(pool_id, (new_from, new_to)); + + // if pool is settled, advance unsettled_swap_id + if (new_from == 0 && self.unsettled_swap_id.read() == pool_id) { + self.unsettled_swap_id.write(pool_id + 1); + } + + remaining_due = remaining_due - take_from; + total_to = total_to + take_to; + pool_id = pool_id + 1; + } + + total_to + } + + // Settle epochs and/or sync settled epochs state + // - If remaining_from > 0: settles epochs by allocating remaining_from to them + // - After settling (or if remaining_from == 0): syncs by checking which epochs are fully settled + // - max_epochs_to_check limits how many epochs to check during sync (0 means check all) + // Epochs are processed in order (1, 2, 3...), but only epochs with subscriptions matter + // Subscriptions can come in any order, so we skip epochs without subscriptions + fn _settle_and_sync_epochs( + ref self: ContractState, + mut remaining_from: u256, + max_epochs_to_check: u256 + ) -> u256 { + // Get the vault's current epoch to know the upper bound + let vault_dispatcher = IVaultDispatcher { contract_address: self.vault.read() }; + let handled_epoch_len = vault_dispatcher.handled_epoch_len(); + + // If no epochs have been handled yet, return early + if (handled_epoch_len == 0) { + return self.last_settled_epoch.read(); + } + + let max_epoch = handled_epoch_len - 1; // -1 because we are using 0-indexed epochs + + // Start from last_settled_epoch + 1 to avoid re-checking already settled epochs + let last_settled_epoch = self.last_settled_epoch.read(); + let mut current_epoch = if last_settled_epoch == 0 { 0 } else { last_settled_epoch + 1 }; + let mut highest_settled_epoch: u256 = last_settled_epoch; + let mut epochs_checked: u256 = 0; + + // If max_epochs_to_check is 0, check all epochs (no limit) + let check_all = max_epochs_to_check == 0; + + // Phase 1: Settle epochs with remaining_from (if any) + while (remaining_from > 0 && current_epoch <= max_epoch) { + let epoch_nominal = self.epoch_wise_nominals.read(current_epoch); + + epochs_checked = epochs_checked + 1; + // designed to prevent excessive gas usage + if (!check_all && epochs_checked > max_epochs_to_check) { + break; + } + + // Skip epochs without subscriptions + if (epoch_nominal == 0) { + // since this function is intended to be called by the swap function, + // the assumption is there is atleast one epoch with subscriptions + // that is waiting to be settled + // - so we can continue to the next epoch, at some point we should + // update the last_settled_epoch + current_epoch = current_epoch + 1; + continue; + } + + let effective_offset = self._get_effective_offset_factor(current_epoch); + let expected_settled = math::u256_mul_div( + epoch_nominal, + effective_offset, + WAD, + math::Rounding::Floor + ); + + let already_settled = self.epoch_settled_amounts.read(current_epoch); + // Check if epoch is already fully settled (handle case where already_settled >= expected_settled) + if (already_settled >= expected_settled) { + if (current_epoch > highest_settled_epoch) { + highest_settled_epoch = current_epoch; + } + current_epoch = current_epoch + 1; + continue; + } + let remaining_to_settle = expected_settled - already_settled; + + let settle_amount = if (remaining_from >= remaining_to_settle) { + remaining_to_settle + } else { + remaining_from + }; + + let new_settled = already_settled + settle_amount; + self.epoch_settled_amounts.write(current_epoch, new_settled); + remaining_from = remaining_from - settle_amount; + + // If epoch is now fully settled, update highest_settled_epoch + if (new_settled >= expected_settled) { + if (current_epoch > highest_settled_epoch) { + highest_settled_epoch = current_epoch; + } + current_epoch = current_epoch + 1; + } else { + // Epoch partially settled, stop here (don't move to next epoch) + break; + } + } + + // Phase 2: Sync epochs (check which epochs are already fully settled) + // This is needed when sync_settled_epochs is called separately after swaps + while (current_epoch <= max_epoch) { + epochs_checked = epochs_checked + 1; + // designed to prevent excessive gas usage + if (!check_all && epochs_checked > max_epochs_to_check) { + break; + } + + let epoch_nominal = self.epoch_wise_nominals.read(current_epoch); + + // Skip epochs without subscriptions + if (epoch_nominal == 0) { + current_epoch = current_epoch + 1; + continue; + } + + // Check if epoch is already fully settled + let effective_offset = self._get_effective_offset_factor(current_epoch); + let expected_settled = math::u256_mul_div( + epoch_nominal, + effective_offset, + WAD, + math::Rounding::Floor + ); + + let already_settled = self.epoch_settled_amounts.read(current_epoch); + + // If epoch is fully settled, update highest_settled_epoch + if (already_settled >= expected_settled && expected_settled > 0) { + if (current_epoch > highest_settled_epoch) { + highest_settled_epoch = current_epoch; + } + current_epoch = current_epoch + 1; + } else { + // Epoch not fully settled, stop here + break; + } + } + + // ideally, only sync until the last settled epoch + let mut last_settled_epoch = highest_settled_epoch; + + // but if called wanted to do check limited blocks, update till then + if (!check_all && current_epoch > highest_settled_epoch) { + last_settled_epoch = current_epoch - 1; + } + self.last_settled_epoch.write(last_settled_epoch); + + last_settled_epoch // return the last settled epoch + } + + // Execute swap via Avnu exchange + fn _execute_avnu_swap( + ref self: ContractState, + routes: Array, + from_amount: u256, + min_amount_out: u256, + ) -> u256 { + let from_asset_address = self._get_from_asset_address(); + let to_asset_address = self.to_asset.read(); + let avnu_exchange_address = self.avnu_exchange.read(); + let this = get_contract_address(); + + // Check balance + let erc20_dispatcher = ERC20ABIDispatcher { contract_address: from_asset_address }; + let balance = erc20_dispatcher.balance_of(this); + if (from_amount > balance) { + Errors::insufficient_from_amount(); + } + + // Get balance before swap + let to_asset_dispatcher = ERC20ABIDispatcher { contract_address: to_asset_address }; + let balance_before = to_asset_dispatcher.balance_of(this); + + // Approve Avnu exchange + erc20_dispatcher.approve(avnu_exchange_address, from_amount); + + // Execute swap + let avnu_dispatcher = IAvnuExchangeDispatcher { contract_address: avnu_exchange_address }; + let swapped = avnu_dispatcher.multi_route_swap( + from_asset_address, + from_amount, + to_asset_address, + 0, + min_amount_out, + this, + self.integrator_fee_amount_bps.read(), + self.integrator_fee_recipient.read(), + routes, + ); + + if (!swapped) { + Errors::swap_failed(); + } + + // Get actual amount received + let balance_after = to_asset_dispatcher.balance_of(this); + balance_after - balance_before + } + + // Get from asset address from vault + fn _get_from_asset_address(self: @ContractState) -> ContractAddress { + let vault_dispatcher = IERC4626Dispatcher { contract_address: self.vault.read() }; + vault_dispatcher.asset() + } + + // Validate common prerequisites for unsubscribe operations + fn _validate_unsubscribe_prerequisites( + self: @ContractState, + nft_id: u256, + ) -> RequestInfo { + let request_info = self.new_nft_request_map.read(nft_id); + + // Validate request info + self._validate_request_info(request_info); + + // Validate epoch exists + let epoch = request_info.epoch; + self._validate_epoch(epoch); + + // Validate owner is the owner of the NFT + let owner = self.erc721.owner_of(nft_id); + if (owner != get_caller_address()) { + Errors::not_owner(); + } + + request_info + } + + // Assert that epoch is not settled (fully or partially) in swap + fn _assert_epoch_not_settled(self: @ContractState, epoch: u256) { + if (self._is_epoch_settled(epoch)) { + Errors::cannot_unsubscribe_partial_swap(); + } + + let settled_amount = self.epoch_settled_amounts.read(epoch); + let expected_settled = self._calculate_expected_settled(epoch); + + if (settled_amount > 0 && settled_amount < expected_settled) { + Errors::cannot_unsubscribe_partial_swap(); + } + } + + // Finalize unsubscribe: mark as unsubscribed, burn NFT, and emit event + fn _finalize_unsubscribe( + ref self: ContractState, + nft_id: u256, + request_info: RequestInfo, + receiver: ContractAddress, + is_old_nft_returned: bool, + original_assets_returned: u256, + ) { + let mut updated = request_info; + updated.unsubscribed = true; + self._update_request_info(nft_id, updated); + self.erc721.burn(nft_id); + + // reduce the epoch_wise_nominals by the nominal of the NFT + let epoch_wise_nominals = self.epoch_wise_nominals.read(request_info.epoch); + self.epoch_wise_nominals.write(request_info.epoch, epoch_wise_nominals - request_info.nominal); + + self.emit(Unsubscribed { + new_nft_id: nft_id, + old_nft_id: request_info.old_nft_id, + owner: receiver, + is_old_nft_returned, + is_original_assets_returned: !is_old_nft_returned, + original_assets_returned + }); + } + } + + #[abi(embed_v0)] + impl RedemptionRouterImpl of IRedemptionRouter { + fn subscribe(ref self: ContractState, nft_id: u256, receiver: ContractAddress) -> u256 { + self.pausable.assert_not_paused(); + + let caller = get_caller_address(); + let vault_dispatcher = IVaultDispatcher { contract_address: self.vault.read() }; + + // Transfer original NFT from caller to this contract + self._transfer_original_nft(nft_id: nft_id, from_address: caller, to_address: get_contract_address()); + + // Use internal helper to handle subscription logic + self._subscribe_internal(nft_id, receiver, vault_dispatcher) + } + + fn redeem_and_subscribe(ref self: ContractState, shares: u256, receiver: ContractAddress) -> u256 { + self.pausable.assert_not_paused(); + + let caller = get_caller_address(); + let this = get_contract_address(); + let vault_address = self.vault.read(); + + // Transfer shares from user to this contract + let vault_erc20_dispatcher = ERC20ABIDispatcher { contract_address: vault_address }; + assert(vault_erc20_dispatcher.transfer_from(caller, this, shares), 'Transfer failed'); + + // Call request_redeem on vault with receiver = this contract + let vault_dispatcher = IVaultDispatcher { contract_address: vault_address }; + let nft_id = vault_dispatcher.request_redeem(shares, this, this); + + // Subscribe the NFT (already owned by this contract) + self._subscribe_internal(nft_id, receiver, vault_dispatcher) + } + + fn swap( + ref self: ContractState, + routes: Array, + from_amount: u256, + min_amount_out: u256, + ) -> u256 { + self.pausable.assert_not_paused(); + self.access_control.assert_only_role(RELAYER_ROLE); + + if (from_amount == 0) { + Errors::invalid_swap_from_amount(); + } + + // Execute swap via Avnu exchange + let to_amount = self._execute_avnu_swap(routes, from_amount, min_amount_out); + + // Settle epochs based on from_amount received + let last_settled_epoch = self._settle_and_sync_epochs(from_amount, 0); + + // Store swap info + let current_swap_id = self.swap_id.read(); + self.swap_info.write(current_swap_id, (from_amount, to_amount)); + self.swap_id.write(current_swap_id + 1); + + // Emit event + self.emit(Swapped { swap_id: current_swap_id, from_amount, to_amount, last_settled_epoch }); + + current_swap_id + } + + fn claim(ref self: ContractState, nft_id: u256) -> u256 { + self.pausable.assert_not_paused(); + + let owner = self.erc721.owner_of(nft_id); + let request_info = self.new_nft_request_map.read(nft_id); + + // Validate request info + self._validate_request_info(request_info); + + // Validate epoch exists + let epoch = request_info.epoch; + self._validate_epoch(epoch); + + // Check if epoch has been settled + if (!self._is_epoch_settled(epoch)) { + Errors::claim_not_allowed(); + } + + // Calculate adjusted due amount with epoch offset factor + // - vault shares to vault asset conversion + let remaining_due = self._calculate_adjusted_due_amount(request_info.due_amount_approximate, epoch); + + // Process swap pools and calculate receivable + let total_to = self._process_swap_pools_for_claim(remaining_due); + + // Burn NFT and mark claimed + self.erc721.burn(nft_id); + let mut updated = request_info; + updated.is_claimed = true; + self._update_request_info(nft_id, updated); + + // Transfer payout + let to_asset_dispatcher = ERC20ABIDispatcher { contract_address: self.to_asset.read() }; + let this = get_contract_address(); + let router_balance = to_asset_dispatcher.balance_of(this); + if (total_to > router_balance) { + Errors::insufficient_balance_for_withdrawal(); + } + to_asset_dispatcher.transfer(owner, total_to); + + self.emit(Claimed { new_nft_id: nft_id, old_nft_id: request_info.old_nft_id, swap_id: self.unsettled_swap_id.read(), receivable: total_to }); + + total_to + } + + + fn unsubscribe_for_nft(ref self: ContractState, nft_id: u256, receiver: ContractAddress) { + self.pausable.assert_not_paused(); + + let request_info = self._validate_unsubscribe_prerequisites(nft_id); + let epoch = request_info.epoch; + + // Revert if epoch is fully or partially settled in swap + self._assert_epoch_not_settled(epoch); + + // Transfer original NFT to receiver + self._transfer_original_nft(nft_id: request_info.old_nft_id, from_address: get_contract_address(), to_address: receiver); + + // Finalize unsubscribe + self._finalize_unsubscribe(nft_id, request_info, receiver, true, 0); + } + + fn unsubscribe_for_underlying(ref self: ContractState, nft_id: u256, receiver: ContractAddress) { + self.pausable.assert_not_paused(); + + let request_info = self._validate_unsubscribe_prerequisites(nft_id); + let epoch = request_info.epoch; + + // Check if epoch < handled_epoch_len (meaning epoch has been handled by vault) + let vault_dispatcher = IVaultDispatcher { contract_address: self.vault.read() }; + let handled_epoch_len = vault_dispatcher.handled_epoch_len(); + + if (epoch >= handled_epoch_len) { + Errors::invalid_nft_id(); // Epoch not yet handled by vault + } + + // Revert if epoch is fully or partially settled in swap + self._assert_epoch_not_settled(epoch); + + // Compute assets based on current offset factor + // due_amount_approximate is the asset amount, adjust it by offset factor + let user_expected_amount = self._calculate_adjusted_due_amount(request_info.due_amount_approximate, epoch); + + // Return assets if contract has balance + let from_asset_address = self._get_from_asset_address(); + let erc20_dispatcher = ERC20ABIDispatcher { contract_address: from_asset_address }; + let this = get_contract_address(); + let contract_balance = erc20_dispatcher.balance_of(this); + + if (user_expected_amount > contract_balance) { + Errors::insufficient_balance_for_withdrawal(); + } + + erc20_dispatcher.transfer(receiver, user_expected_amount); + + // Finalize unsubscribe + self._finalize_unsubscribe(nft_id, request_info, receiver, false, user_expected_amount); + } + + fn set_integrator_fee_recipient(ref self: ContractState, recipient: ContractAddress) { + self.access_control.assert_only_role(OWNER_ROLE); + if (recipient.is_zero()) { + Errors::zero_address(); + } + self.integrator_fee_recipient.write(recipient); + self.emit(IntegratorFeeRecipientSet { recipient }); + } + + fn set_integrator_fee_amount_bps(ref self: ContractState, fee_bps: u128) { + self.access_control.assert_only_role(OWNER_ROLE); + + // Ensure fee doesn't exceed 5% (500 bps) + if (fee_bps > 500) { // MAX_INTEGRATOR_FEES_BPS from Avnu + Errors::invalid_fee_amount(); + } + self.integrator_fee_amount_bps.write(fee_bps); + self.emit(IntegratorFeeAmountBpsSet { fee_bps }); + } + + fn set_epoch_offset(ref self: ContractState, epoch: u256, offset_factor: u256) { + self.access_control.assert_only_role(RELAYER_ROLE); + self.epoch_offset_factor.write(epoch, offset_factor); + } + + fn get_epoch_offset(self: @ContractState, epoch: u256) -> u256 { + self.epoch_offset_factor.read(epoch) + } + + fn report(ref self: ContractState, new_aum: u256) { + self.access_control.assert_only_role(RELAYER_ROLE); + + let vault_dispatcher = IVaultDispatcher { contract_address: self.vault.read() }; + + // Read handled_epoch_len before calling report + let handled_epochs_before = vault_dispatcher.handled_epoch_len(); + + // Call vault's report function + vault_dispatcher.report(new_aum); + + // Read handled_epoch_len after calling report + let handled_epochs_after = vault_dispatcher.handled_epoch_len(); + + // For each newly handled epoch, compute and update offset factor + let mut epoch = handled_epochs_before; + while (epoch <= handled_epochs_after) { + // Read new redeem_assets and redeem_nominal after report + let new_redeem_assets = vault_dispatcher.redeem_assets(epoch); + let new_redeem_nominal = vault_dispatcher.redeem_nominal(epoch); + + // Read old snapshots + let old_redeem_assets = self.epoch_redeem_assets.read(epoch); + let old_redeem_nominal = self.epoch_redeem_nominal.read(epoch); + + // Compute new offset factor: WAD * (new_redeem_assets / new_redeem_nominal) / (old_redeem_assets / old_redeem_nominal) + // This simplifies to: WAD * new_redeem_assets * old_redeem_nominal / (new_redeem_nominal * old_redeem_assets) + if (old_redeem_assets > 0 && old_redeem_nominal > 0 && new_redeem_nominal > 0) { + // Compute numerator: WAD * new_redeem_assets * old_redeem_nominal + let numerator = WAD * new_redeem_assets * old_redeem_nominal; + // Compute denominator: new_redeem_nominal * old_redeem_assets + let denominator = new_redeem_nominal * old_redeem_assets; + // Compute: numerator / denominator + let new_offset_factor = math::u256_mul_div( + numerator, + 1, + denominator, + math::Rounding::Floor + ); + self.epoch_offset_factor.write(epoch, new_offset_factor); + } + + epoch = epoch + 1; + } + } + + fn set_min_subscribe_amount(ref self: ContractState, min_subscribe_amount: u256) { + self.access_control.assert_only_role(OWNER_ROLE); + self.min_subscribe_amount.write(min_subscribe_amount); + self.emit(MinSubscribeAmountSet { min_subscribe_amount }); + } + + fn pause(ref self: ContractState) { + self.access_control.assert_only_role(PAUSER_ROLE); + self.pausable.pause(); + } + + fn unpause(ref self: ContractState) { + self.access_control.assert_only_role(OWNER_ROLE); + self.pausable.unpause(); + } + + fn vault(self: @ContractState) -> ContractAddress { + self.vault.read() + } + + fn redeem_request(self: @ContractState) -> ContractAddress { + self.redeem_request.read() + } + + fn to_asset(self: @ContractState) -> ContractAddress { + self.to_asset.read() + } + + fn avnu_exchange(self: @ContractState) -> ContractAddress { + self.avnu_exchange.read() + } + + fn integrator_fee_recipient(self: @ContractState) -> ContractAddress { + self.integrator_fee_recipient.read() + } + + fn integrator_fee_amount_bps(self: @ContractState) -> u128 { + self.integrator_fee_amount_bps.read() + } + + fn swap_id(self: @ContractState) -> u256 { + self.swap_id.read() + } + + fn unsettled_swap_id(self: @ContractState) -> u256 { + self.unsettled_swap_id.read() + } + + fn new_nft_request_info(self: @ContractState, new_nft_id: u256) -> RequestInfo { + self.new_nft_request_map.read(new_nft_id) + } + + fn swap_info(self: @ContractState, swap_id: u256) -> (u256, u256) { + self.swap_info.read(swap_id) + } + + fn last_nft_id(self: @ContractState) -> u256 { + let counter = self.nft_id_counter.read(); + if (counter == 0) { + 0 + } else { + counter - 1 + } + } + + fn expected_receivable(self: @ContractState, nft_id: u256) -> u256 { + let request_info = self.new_nft_request_map.read(nft_id); + + // If already claimed or unsubscribed, return 0 + if (request_info.is_claimed || request_info.unsubscribed || request_info.due_amount_approximate == 0) { + return 0; + } + + // Calculate adjusted due amount with epoch offset factor + let remaining_due = self._calculate_adjusted_due_amount(request_info.due_amount_approximate, request_info.epoch); + + // Calculate receivable from swap pools (read-only) + self._calculate_receivable_from_pools(remaining_due) + } + + fn last_settled_epoch(self: @ContractState) -> u256 { + self.last_settled_epoch.read() + } + + fn epoch_settled_amounts(self: @ContractState, epoch: u256) -> u256 { + self.epoch_settled_amounts.read(epoch) + } + + // Useful when there are many empty epochs. + // Allows batching their settlement without running out of gas. + fn sync_settled_epochs(ref self: ContractState, max_epochs_to_check: u256) { + self.pausable.assert_not_paused(); + self._settle_and_sync_epochs(remaining_from: 0, max_epochs_to_check: max_epochs_to_check); + } + } + + #[abi(embed_v0)] + impl UpgradeableImpl of IUpgradeable { + fn upgrade(ref self: ContractState, new_class_hash: starknet::ClassHash) { + self.access_control.assert_only_role(OWNER_ROLE); + self.upgradeable.upgrade(new_class_hash); + } + } +} + diff --git a/packages/vault/src/test/units/redemption_router.cairo b/packages/vault/src/test/units/redemption_router.cairo new file mode 100644 index 00000000..68d6fe52 --- /dev/null +++ b/packages/vault/src/test/units/redemption_router.cairo @@ -0,0 +1,1377 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 Starknet Vault Kit +// Licensed under the MIT License. See LICENSE file for details. + +use openzeppelin::interfaces::accesscontrol::{ + IAccessControlDispatcher, IAccessControlDispatcherTrait, +}; +use openzeppelin::interfaces::erc20::{ERC20ABIDispatcher, ERC20ABIDispatcherTrait}; +use openzeppelin::interfaces::erc4626::{IERC4626Dispatcher, IERC4626DispatcherTrait}; +use openzeppelin::interfaces::erc721::{ERC721ABIDispatcher, ERC721ABIDispatcherTrait}; +use snforge_std::{ + CheatSpan, ContractClassTrait, DeclareResultTrait, cheat_caller_address, declare, +}; +use starknet::{ContractAddress, get_block_timestamp}; +use core::array::ArrayTrait; +use vault::redeem_request::interface::{ + IRedeemRequestDispatcher, +}; +use vault::redemption_router::interface::{ + IRedemptionRouterDispatcher, IRedemptionRouterDispatcherTrait, +}; +use vault::redemption_router::redemption_router::RedemptionRouter; +use vault::test::utils::{OWNER, USER1, USER2, WAD, deploy_erc20_mock, deploy_redeem_request, deploy_vault, VAULT_ALLOCATOR, ORACLE}; +use snforge_std::start_cheat_block_timestamp_global; +use vault::vault::interface::{IVaultDispatcher, IVaultDispatcherTrait}; +use vault::vault::vault::Vault; +use vault_allocator::decoders_and_sanitizers::decoder_custom_types::Route; +use vault_allocator::mocks::mock_avnu_exchange::IAvnuExchangeDispatcher; + +const RELAYER: ContractAddress = 0x1234567890.try_into().unwrap(); + +fn deploy_redemption_router( + vault: ContractAddress, + redeem_request: ContractAddress, + to_asset: ContractAddress, + avnu_exchange: ContractAddress, + integrator_fee_recipient: ContractAddress, + integrator_fee_amount_bps: u128, + min_subscribe_amount: u256, +) -> IRedemptionRouterDispatcher { + println!("deploying redemption router"); + let router = declare("RedemptionRouter").unwrap().contract_class(); + let mut calldata = ArrayTrait::new(); + OWNER().serialize(ref calldata); + vault.serialize(ref calldata); + redeem_request.serialize(ref calldata); + to_asset.serialize(ref calldata); + avnu_exchange.serialize(ref calldata); + integrator_fee_recipient.serialize(ref calldata); + integrator_fee_amount_bps.serialize(ref calldata); + min_subscribe_amount.serialize(ref calldata); + println!("deploying redemption router with calldata"); + // let (router_address, _) = router.deploy(@calldata).unwrap(); + let res = router.deploy(@calldata); + match res { + Ok((router_address, _)) => IRedemptionRouterDispatcher { contract_address: router_address }, + Err(e) => { + let err = *e.at(2); + // to ensure exact error of panic is thrown + assert(false, err); + // just a fallback for compiling purposes + panic!("error deploying redemption router"); + } + } +} + +fn deploy_mock_avnu_exchange() -> IAvnuExchangeDispatcher { + let avnu = declare("MockAvnuExchange").unwrap().contract_class(); + let mut calldata = ArrayTrait::new(); + let (avnu_address, _) = avnu.deploy(@calldata).unwrap(); + println!("avnu_address: {:?}", avnu_address); + IAvnuExchangeDispatcher { contract_address: avnu_address } +} + +fn set_up() -> ( + IVaultDispatcher, // vault + ContractAddress, // from_asset + ContractAddress, // to_asset + IRedeemRequestDispatcher, // redeem_request + IAvnuExchangeDispatcher, // avnu_exchange + IRedemptionRouterDispatcher, // router +) { + + let from_asset = deploy_erc20_mock(); + let to_asset = deploy_erc20_mock(); + let vault = deploy_vault( + from_asset + ); + let vault_address = vault.contract_address; + + let redeem_request = deploy_redeem_request(vault_address); + // Register the deployed redeem_request with vault + cheat_caller_address(vault_address, OWNER(), span: CheatSpan::TargetCalls(1)); + vault.register_redeem_request(redeem_request.contract_address); + + // Register vault_allocator if needed + let vault_allocator = VAULT_ALLOCATOR(); + cheat_caller_address(vault_address, OWNER(), span: CheatSpan::TargetCalls(1)); + vault.register_vault_allocator(vault_allocator); + + // overwrite set fees + cheat_caller_address(vault_address, OWNER(), span: CheatSpan::TargetCalls(1)); + vault.set_fees_config(OWNER(), 0, 0, Vault::WAD / 10); + + // Grant ORACLE_ROLE to ORACLE for report calls + let access_control_vault = IAccessControlDispatcher { + contract_address: vault_address, + }; + cheat_caller_address(vault_address, OWNER(), span: CheatSpan::TargetCalls(1)); + access_control_vault.grant_role(Vault::ORACLE_ROLE, ORACLE()); + + let avnu_exchange = deploy_mock_avnu_exchange(); + println!("avnu_exchange deployed"); + let integrator_fee_recipient = 'FEE_RECIPIENT'.try_into().unwrap(); + let integrator_fee_amount_bps: u128 = 100; // 1% + let min_subscribe_amount: u256 = 0; // No minimum by default for tests + println!("integrator_fee_recipient and integrator_fee_amount_bps set"); + + let router = deploy_redemption_router( + vault_address, + redeem_request.contract_address, + to_asset, + avnu_exchange.contract_address, + integrator_fee_recipient, + integrator_fee_amount_bps, + min_subscribe_amount, + ); + + println!("router: {:?}", router.contract_address); + + // Grant RELAYER_ROLE and PAUSER_ROLE + let access_control = IAccessControlDispatcher { + contract_address: router.contract_address, + }; + cheat_caller_address(router.contract_address, OWNER(), span: CheatSpan::TargetCalls(1)); + access_control.grant_role(RedemptionRouter::RELAYER_ROLE, RELAYER); + cheat_caller_address(router.contract_address, OWNER(), span: CheatSpan::TargetCalls(1)); + access_control.grant_role(selector!("PAUSER_ROLE"), OWNER()); + println!("RELAYER_ROLE granted"); + + // to avoid zero liquidity error, seed some initial liquidity + let due_amount_1: u256 = WAD * 100; + deposit_to_user(vault, OWNER(), due_amount_1); + + (vault, from_asset, to_asset, redeem_request, avnu_exchange, router) +} + +fn deposit_to_user( + vault: IVaultDispatcher, user: ContractAddress, nominal: u256, +) -> u256 { + // First, user needs to deposit assets to get vault shares + // Get asset address from vault + let erc4626_dispatcher = IERC4626Dispatcher { contract_address: vault.contract_address }; + let asset_address = erc4626_dispatcher.asset(); + + // Transfer underlying assets to user + let asset_dispatcher = ERC20ABIDispatcher { contract_address: asset_address }; + cheat_caller_address(asset_address, OWNER(), span: CheatSpan::TargetCalls(1)); + asset_dispatcher.transfer(user, nominal); + + // User approves vault to spend assets + cheat_caller_address(asset_address, user, span: CheatSpan::TargetCalls(1)); + asset_dispatcher.approve(vault.contract_address, nominal); + + // User deposits assets to get shares + cheat_caller_address(vault.contract_address, user, span: CheatSpan::TargetCalls(1)); + let shares = erc4626_dispatcher.deposit(nominal, user); + + shares +} + +fn mint_and_redeem_old_nft_to_user( + vault: IVaultDispatcher, user: ContractAddress, nominal: u256, +) -> u256 { + let shares = deposit_to_user(vault, user, nominal); + + // Call request_redeem on vault as if user is trying to withdraw + // Now call request_redeem as the user + cheat_caller_address(vault.contract_address, user, span: CheatSpan::TargetCalls(1)); + vault.request_redeem(shares, user, user) +} + +fn report(vault: IVaultDispatcher, from_asset: ContractAddress) { + // increase timestamp, else report fails + let now = get_block_timestamp(); + start_cheat_block_timestamp_global(now + 3600); // 1 hour + + // First, call report on vault to handle epochs + // Report needs ORACLE_ROLE + let oracle = ORACLE(); + + // After report, if all epochs are handled, excess funds are sent to vault_allocator + // We need to mock bring_liquidity to get them back + let vault_allocator_addr = vault.vault_allocator(); + // bring_liquidity transfers FROM caller TO vault + let asset_dispatcher = ERC20ABIDispatcher { contract_address: from_asset }; + let allocator_balance = asset_dispatcher.balance_of(vault_allocator_addr); + if allocator_balance > 0 { + // Transfer funds from allocator back to vault (simulating bring_liquidity) + cheat_caller_address(from_asset, vault_allocator_addr, span: CheatSpan::TargetCalls(1)); + asset_dispatcher.approve(vault.contract_address, allocator_balance); + + // Mock the bring_liquidity call to update vault state + cheat_caller_address(vault.contract_address, vault_allocator_addr, span: CheatSpan::TargetCalls(1)); + vault.bring_liquidity(allocator_balance); + } + + let handled_epoch_len = vault.handled_epoch_len(); + println!("pre::handled_epoch_len: {}", handled_epoch_len); + let erc4626_dispatcher = IERC4626Dispatcher { contract_address: vault.contract_address }; + println!("pre::total_assets: {}", erc4626_dispatcher.total_assets()); + println!("pre::total_supply: {}", erc4626_dispatcher.total_assets()); + + // Call report as oracle + cheat_caller_address(vault.contract_address, oracle, span: CheatSpan::TargetCalls(1)); + vault.report(0); // no assets in vault allocator + let handled_epoch_len = vault.handled_epoch_len(); + println!("post::handled_epoch_len: {}", handled_epoch_len); + println!("post::total_assets: {}", erc4626_dispatcher.total_assets()); + println!("post::total_supply: {}", erc4626_dispatcher.total_assets()); + +} +fn fulfill_old_nft( + vault: IVaultDispatcher, from_asset: ContractAddress, nft_id: u256, +) { + + report(vault, from_asset); + let asset_dispatcher = ERC20ABIDispatcher { contract_address: from_asset }; + + // Now claim_redeem on vault + // Get NFT owner first + let redeem_request_addr = vault.redeem_request(); + let erc721_dispatcher = ERC721ABIDispatcher { contract_address: redeem_request_addr }; + let nft_owner = erc721_dispatcher.owner_of(nft_id); + + let owner_balance = asset_dispatcher.balance_of(nft_owner); + println!("owner_balance: {}", owner_balance); + + // Call claim_redeem as the NFT owner + cheat_caller_address(vault.contract_address, nft_owner, span: CheatSpan::TargetCalls(1)); + vault.claim_redeem(nft_id); + let owner_balance_after = asset_dispatcher.balance_of(nft_owner); + println!("owner_balance_after: {}", owner_balance_after); +} + +fn mark_old_nft_fulfilled(router_address: ContractAddress, old_nft_id: u256) { + // // Mark old NFT as fulfilled in router's storage + // let mut cheat_calldata_key = ArrayTrait::new(); + // old_nft_id.serialize(ref cheat_calldata_key); + // let mut cheat_calldata_value = ArrayTrait::new(); + // true.serialize(ref cheat_calldata_value); + // let map_entry = map_entry_address(selector!("old_nft_fulfilled"), cheat_calldata_key.span()); + // store(router_address, map_entry, cheat_calldata_value.span()); +} + +// ============================================================================ +// 1. Constructor & Initialization Tests +// ============================================================================ + +#[test] +fn test_constructor_initializes_correctly() { + let (vault, _, to_asset, redeem_request, avnu_exchange, router) = set_up(); + println!("setup done"); + + // Verify addresses are stored correctly + assert(router.vault() == vault.contract_address, 'Vault address incorrect'); + assert(router.redeem_request() == redeem_request.contract_address, 'Redeem request incorrect'); + assert(router.to_asset() == to_asset, 'To asset incorrect'); + assert(router.avnu_exchange() == avnu_exchange.contract_address, 'Avnu exchange incorrect'); + println!("addresses stored correctly"); + + // Verify roles are set + let access_control = IAccessControlDispatcher { + contract_address: router.contract_address, + }; + let has_owner_role = access_control.has_role(selector!("OWNER_ROLE"), OWNER()); + assert(has_owner_role, 'Owner role not set'); + println!("roles set"); + // Verify swap_id and unsettled_swap_id start at 1 + assert(router.swap_id() == 1, 'swap_id should start at 1'); + assert(router.unsettled_swap_id() == 1, 'unsettled_swap_id != 1'); + println!("swap_id and unsettled_swap_id start at 1"); + // Verify NFT counter starts at 0 (but we can't directly read it, so check via first mint) + // Actually, we can't verify this without minting, but the contract code shows it's initialized to 0 + println!("NFT counter starts at 0"); + // Verify NFT contract initialized + let erc721 = ERC721ABIDispatcher { contract_address: router.contract_address }; + assert(erc721.name() == "RedemptionRouter", 'NFT name incorrect'); + assert(erc721.symbol() == "RR", 'NFT symbol incorrect'); + + // Verify last_settled_epoch initialized (should be 0 when handled_epoch_len is 0) + assert(router.last_settled_epoch() == 0, 'last_settled_epoch should be 0'); +} + +#[test] +#[should_panic(expected: ('Zero address',))] +fn test_constructor_reverts_zero_vault() { + let zero_vault: ContractAddress = core::num::traits::Zero::zero(); + let to_asset = deploy_erc20_mock(); + // Note: deploy_redeem_request will fail with zero vault, so we skip it + // and pass zero directly to router constructor which should check it + let dummy_redeem_request: ContractAddress = 'DUMMY_RR'.try_into().unwrap(); + let avnu_exchange: ContractAddress = 'AVNU_EXCHANGE'.try_into().unwrap(); + let integrator_fee_recipient = 'FEE_RECIPIENT'.try_into().unwrap(); + deploy_redemption_router( + zero_vault, + dummy_redeem_request, + to_asset, + avnu_exchange, + integrator_fee_recipient, + 100, + 0, + ); +} + +#[test] +#[should_panic(expected: ('Zero address',))] +fn test_constructor_reverts_zero_redeem_request() { + let dummy_vault = 'DUMMY_VAULT'.try_into().unwrap(); + let to_asset = deploy_erc20_mock(); + let avnu_exchange: ContractAddress = 'AVNU_EXCHANGE'.try_into().unwrap(); + let integrator_fee_recipient = 'FEE_RECIPIENT'.try_into().unwrap(); + let zero_redeem_request: ContractAddress = core::num::traits::Zero::zero(); + deploy_redemption_router(dummy_vault, zero_redeem_request, to_asset, avnu_exchange, integrator_fee_recipient, 100, 0); +} + +#[test] +#[should_panic(expected: ('Zero address',))] +fn test_constructor_reverts_zero_to_asset() { + let dummy_vault = 'DUMMY_VAULT'.try_into().unwrap(); + let redeem_request: ContractAddress = 'DUMMY_RR'.try_into().unwrap(); + let avnu_exchange = 'AVNU_EXCHANGE'.try_into().unwrap(); + let integrator_fee_recipient = 'FEE_RECIPIENT'.try_into().unwrap(); + let zero_to_asset: ContractAddress = core::num::traits::Zero::zero(); + deploy_redemption_router( + dummy_vault, + redeem_request, + zero_to_asset, + avnu_exchange, + integrator_fee_recipient, + 100, + 0, + ); +} + +// ============================================================================ +// 2. Subscribe Function Tests +// ============================================================================ + +#[test] +fn test_subscribe_transfers_old_nft_and_mints_new() { + let (vault, _, _, redeem_request, _, router) = set_up(); + + // Mint old NFT to user by calling request_redeem on vault + let due_amount: u256 = WAD * 100; // due_amount equals nominal in WAD + let old_nft_id = mint_and_redeem_old_nft_to_user(vault, USER1(), due_amount); + let epoch: u256 = vault.epoch(); // Get current epoch from vault + + // User approves router + let erc721_dispatcher = ERC721ABIDispatcher { + contract_address: redeem_request.contract_address, + }; + cheat_caller_address(redeem_request.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id); + + // User subscribes + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + let new_nft_id = router.subscribe(old_nft_id, USER1()); + + // Verify new NFT was minted + let router_erc721 = ERC721ABIDispatcher { contract_address: router.contract_address }; + assert(router_erc721.owner_of(new_nft_id) == USER1(), 'New NFT owner incorrect'); + + // Verify old NFT was transferred to router (on redeem_request contract) + let redeem_request_erc721 = ERC721ABIDispatcher { + contract_address: redeem_request.contract_address, + }; + assert(redeem_request_erc721.owner_of(old_nft_id) == router.contract_address, 'Old NFT not transferred'); + + // Verify mapping stored correctly + let request_info = router.new_nft_request_info(new_nft_id); + assert(request_info.old_nft_id == old_nft_id, 'Old NFT ID mapping incorrect'); + assert(request_info.is_claimed == false, 'is_claimed should be false'); + assert(request_info.epoch == epoch, 'Epoch stored incorrectly'); + assert(request_info.due_amount_approximate == due_amount, 'Due amount stored incorrectly'); + + // Verify new_nft_id is 0 (first NFT) + assert(new_nft_id == 0, 'First NFT ID should be 0'); +} + +#[test] +fn test_subscribe_increments_nft_counter() { + let (vault, _, _, redeem_request, _, router) = set_up(); + + // Subscribe first NFT + let due_amount_1: u256 = WAD * 100; + let old_nft_id_1 = mint_and_redeem_old_nft_to_user(vault, USER1(), due_amount_1); + + let erc721_dispatcher = ERC721ABIDispatcher { + contract_address: redeem_request.contract_address, + }; + cheat_caller_address(redeem_request.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id_1); + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + let new_nft_id_1 = router.subscribe(old_nft_id_1, USER1()); + assert(new_nft_id_1 == 0, 'First NFT ID should be 0'); + + // Subscribe second NFT + let due_amount_2: u256 = WAD * 200; + let old_nft_id_2 = mint_and_redeem_old_nft_to_user(vault, USER2(), due_amount_2); + + cheat_caller_address(redeem_request.contract_address, USER2(), span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id_2); + cheat_caller_address(router.contract_address, USER2(), span: CheatSpan::TargetCalls(1)); + let new_nft_id_2 = router.subscribe(old_nft_id_2, USER2()); + assert(new_nft_id_2 == 1, 'Second NFT ID should be 1'); +} + +#[test] +#[should_panic(expected: ('Pausable: paused',))] +fn test_subscribe_reverts_when_paused() { + let (vault, _, _, redeem_request, _, router) = set_up(); + + // Pause contract + cheat_caller_address(router.contract_address, OWNER(), span: CheatSpan::TargetCalls(1)); + router.pause(); + + // Attempt subscribe + let due_amount: u256 = WAD * 100; + let old_nft_id = mint_and_redeem_old_nft_to_user(vault, USER1(), due_amount); + + let erc721_dispatcher = ERC721ABIDispatcher { + contract_address: redeem_request.contract_address, + }; + cheat_caller_address(redeem_request.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id); + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + router.subscribe(old_nft_id, USER1()); +} + +#[test] +#[should_panic(expected: "Too small subscribe amount")] +fn test_subscribe_reverts_on_too_small_amount() { + let (vault, _, _, redeem_request, _, router) = set_up(); + + // Set min_subscribe_amount + cheat_caller_address(router.contract_address, OWNER(), span: CheatSpan::TargetCalls(1)); + router.set_min_subscribe_amount(WAD * 100); + + // Attempt subscribe with amount below minimum + let due_amount: u256 = WAD * 50; + let old_nft_id = mint_and_redeem_old_nft_to_user(vault, USER1(), due_amount); // 50 < 100 + + let erc721_dispatcher = ERC721ABIDispatcher { + contract_address: redeem_request.contract_address, + }; + cheat_caller_address(redeem_request.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id); + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + router.subscribe(old_nft_id, USER1()); +} + +#[test] +#[should_panic(expected: "Epoch already handled")] +fn test_subscribe_reverts_when_epoch_already_handled() { + let (vault, _, _, redeem_request, _, router) = set_up(); + + // 1. Mint the NFT (redeem request) but don't subscribe + let due_amount: u256 = WAD * 100; + let old_nft_id = mint_and_redeem_old_nft_to_user(vault, USER1(), due_amount); + + // Verify NFT was minted and epoch is current + let epoch_before_report = vault.epoch(); + let handled_epochs_before = vault.handled_epoch_len(); + assert(handled_epochs_before == 0, 'Should start with 0'); + + // 2. Report - this handles the epoch + // increase timestamp, else report fails + let now = get_block_timestamp(); + start_cheat_block_timestamp_global(now + 3600); // 1 hour + + let oracle = ORACLE(); + cheat_caller_address(vault.contract_address, oracle, span: CheatSpan::TargetCalls(1)); + vault.report(0); + + // Verify epoch was handled + let handled_epochs_after = vault.handled_epoch_len(); + assert(handled_epochs_after == 1, 'Epoch 0 should be handled'); + assert(epoch_before_report < handled_epochs_after, 'Epoch should be handled'); + + // 3. Try to subscribe - should fail with "Epoch already handled" + let erc721_dispatcher = ERC721ABIDispatcher { + contract_address: redeem_request.contract_address, + }; + cheat_caller_address(redeem_request.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id); + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + router.subscribe(old_nft_id, USER1()); // Should panic with "Epoch already handled" +} + +#[test] +fn test_redeem_and_subscribe_transfers_shares_and_subscribes() { + let (vault, _, _, redeem_request, _, router) = set_up(); + + // User deposits assets to get vault shares + let nominal: u256 = WAD * 100; + let shares = deposit_to_user(vault, USER1(), nominal); + let epoch: u256 = vault.epoch(); // Get current epoch from vault + + // User approves router to transfer shares + let vault_erc20_dispatcher = ERC20ABIDispatcher { contract_address: vault.contract_address }; + cheat_caller_address(vault.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + vault_erc20_dispatcher.approve(router.contract_address, shares); + + // Get user's share balance before + let user_shares_before = vault_erc20_dispatcher.balance_of(USER1()); + + // Call redeem_and_subscribe + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + let new_nft_id = router.redeem_and_subscribe(shares, USER1()); + + // Verify new NFT was minted to receiver + let router_erc721 = ERC721ABIDispatcher { contract_address: router.contract_address }; + assert(router_erc721.owner_of(new_nft_id) == USER1(), 'New NFT owner incorrect'); + + // Verify user's shares were transferred (burned by vault during request_redeem) + let user_shares_after = vault_erc20_dispatcher.balance_of(USER1()); + assert(user_shares_after == user_shares_before - shares, 'User shares not transferred'); + + // Verify old NFT (from request_redeem) is owned by router + // The NFT ID returned from request_redeem is stored in old_nft_id + let request_info = router.new_nft_request_info(new_nft_id); + let old_nft_id = request_info.old_nft_id; + let redeem_request_erc721 = ERC721ABIDispatcher { + contract_address: redeem_request.contract_address, + }; + assert(redeem_request_erc721.owner_of(old_nft_id) == router.contract_address, 'Old NFT not owned by router'); + + // Verify mapping stored correctly + assert(request_info.is_claimed == false, 'is_claimed should be false'); + assert(request_info.epoch == epoch, 'Epoch stored incorrectly'); + assert(request_info.due_amount_approximate > 0, 'Due amount should be set'); + assert(request_info.unsubscribed == false, 'unsubscribed should be false'); + + // Verify new_nft_id is correct (should be 0 if this is the first subscription) + assert(new_nft_id == 0, 'First NFT ID should be 0'); +} + +// ============================================================================ +// 3. Swap Function Tests +// ============================================================================ + +#[test] +fn test_swap_executes_successfully() { + let (vault, from_asset, to_asset, _, avnu_exchange, router) = set_up(); + + // Mint from_asset tokens to router + let from_asset_dispatcher = ERC20ABIDispatcher { contract_address: from_asset }; + let router_address = router.contract_address; + cheat_caller_address(from_asset, OWNER(), span: CheatSpan::TargetCalls(1)); + from_asset_dispatcher.transfer(router_address, WAD * 10); // 10 tokens + + // Mint to_asset tokens to mock exchange so it can transfer them back + let to_asset_dispatcher = ERC20ABIDispatcher { contract_address: to_asset }; + cheat_caller_address(to_asset, OWNER(), span: CheatSpan::TargetCalls(1)); + to_asset_dispatcher.transfer(avnu_exchange.contract_address, WAD * 100); + + // Execute swap + let routes: Array = array![]; + let from_amount: u256 = WAD * 5; // 5 tokens + let min_amount_out: u256 = WAD * 4; // 4 tokens (2:1 ratio for simplicity) + + cheat_caller_address(router.contract_address, RELAYER, span: CheatSpan::TargetCalls(1)); + let swap_id = router.swap(routes, from_amount, min_amount_out); + + // Verify swap_id is 1 (first swap) + assert(swap_id == 1, 'swap_id should be 1'); + + // Verify swap_info stores correct amounts + let (stored_from, stored_to) = router.swap_info(swap_id); + assert(stored_from == from_amount, 'Stored from_amount incorrect'); + assert(stored_to == min_amount_out, 'Stored to_amount incorrect'); + + // Verify swap_id incremented + assert(router.swap_id() == 2, 'swap_id should increment to 2'); +} + +#[test] +#[should_panic(expected: "Insufficient from amount")] +fn test_swap_reverts_on_insufficient_balance() { + let (vault, from_asset, _, _, _, router) = set_up(); + + // Don't mint any tokens to router (has 0 balance) + + // Attempt swap + let routes: Array = array![]; + let from_amount: u256 = WAD * 5; + let min_amount_out: u256 = WAD * 4; + + cheat_caller_address(router.contract_address, RELAYER, span: CheatSpan::TargetCalls(1)); + router.swap(routes, from_amount, min_amount_out); +} + +#[test] +#[should_panic(expected: ('Caller is missing role',))] +fn test_swap_reverts_when_not_relayer() { + let (vault, from_asset, _, _, _, router) = set_up(); + + // Mint tokens to router + let from_asset_dispatcher = ERC20ABIDispatcher { contract_address: from_asset }; + cheat_caller_address(from_asset, OWNER(), span: CheatSpan::TargetCalls(1)); + from_asset_dispatcher.transfer(router.contract_address, WAD * 10); + + // Attempt swap as non-relayer + let routes: Array = array![]; + let from_amount: u256 = WAD * 5; + let min_amount_out: u256 = WAD * 4; + + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + router.swap(routes, from_amount, min_amount_out); +} + +#[test] +#[should_panic(expected: ('Pausable: paused',))] +fn test_swap_reverts_when_paused() { + let (vault, from_asset, _, _, _, router) = set_up(); + + // Pause contract + cheat_caller_address(router.contract_address, OWNER(), span: CheatSpan::TargetCalls(1)); + router.pause(); + + // Attempt swap + let routes: Array = array![]; + let from_amount: u256 = WAD * 5; + let min_amount_out: u256 = WAD * 4; + + cheat_caller_address(router.contract_address, RELAYER, span: CheatSpan::TargetCalls(1)); + router.swap(routes, from_amount, min_amount_out); +} + +// Note: test_swap_reverts_on_avnu_failure is skipped because: +// - The mock_avnu_exchange always returns true +// - To test failure, we would need a variant mock or different approach +// - The implementation correctly checks for swapped == false and reverts with "Swap failed" +// - This test case is documented in the test plan but requires mock modification to implement + +#[test] +fn test_swap_uses_actual_received_amount() { + let (vault, from_asset, to_asset, _, avnu_exchange, router) = set_up(); + + // Mint from_asset tokens to router + let from_asset_dispatcher = ERC20ABIDispatcher { contract_address: from_asset }; + cheat_caller_address(from_asset, OWNER(), span: CheatSpan::TargetCalls(1)); + from_asset_dispatcher.transfer(router.contract_address, WAD * 10); + + // Mint to_asset tokens to mock exchange so it can transfer them + let to_asset_dispatcher = ERC20ABIDispatcher { contract_address: to_asset }; + cheat_caller_address(to_asset, OWNER(), span: CheatSpan::TargetCalls(1)); + to_asset_dispatcher.transfer(avnu_exchange.contract_address, WAD * 100); + + // Get initial to_asset balance (not used but kept for reference) + let _initial_to_balance = to_asset_dispatcher.balance_of(router.contract_address); + + // Execute swap with min_amount_out = 4 + let routes: Array = array![]; + let from_amount: u256 = WAD * 5; + let min_amount_out: u256 = WAD * 4; + + cheat_caller_address(router.contract_address, RELAYER, span: CheatSpan::TargetCalls(1)); + let swap_id = router.swap(routes, from_amount, min_amount_out); + + // Verify swap_info stores actual received amount (balance delta) + let (stored_from, stored_to) = router.swap_info(swap_id); + assert(stored_from == from_amount, 'Stored from_amount incorrect'); + // Verify stored to_amount matches what was actually received (min_amount_out) + assert(stored_to == min_amount_out, 'Stored to_amount invalid'); +} + +// ============================================================================ +// 4. Basic Claim Scenarios +// ============================================================================ + +#[test] +fn test_claim_single_subscribe_single_swap_single_claim() { + let (vault, from_asset, to_asset, redeem_request, avnu_exchange, router) = set_up(); + + // 1. Subscribe + let due_amount: u256 = WAD * 100; + let old_nft_id = mint_and_redeem_old_nft_to_user(vault, USER1(), due_amount); + + let erc721_dispatcher = ERC721ABIDispatcher { + contract_address: redeem_request.contract_address, + }; + cheat_caller_address(redeem_request.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id); + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + let new_nft_id = router.subscribe(old_nft_id, USER1()); + + // 2. Fulfill old NFT (burn it) + fulfill_old_nft(vault, from_asset, old_nft_id); + + // 3. Transfer assets to router (simulate vault fulfilling redemption) + let from_asset_dispatcher = ERC20ABIDispatcher { contract_address: from_asset }; + cheat_caller_address(from_asset, OWNER(), span: CheatSpan::TargetCalls(1)); + from_asset_dispatcher.transfer(router.contract_address, WAD * 100); // 100 tokens + + // 4. Mint to_asset to mock exchange for swap + let to_asset_dispatcher = ERC20ABIDispatcher { contract_address: to_asset }; + cheat_caller_address(to_asset, OWNER(), span: CheatSpan::TargetCalls(1)); + to_asset_dispatcher.transfer(avnu_exchange.contract_address, WAD * 300); + + // 5. Swap + let routes: Array = array![]; + let from_amount: u256 = WAD * 100; + let min_amount_out: u256 = WAD * 200; // 2:1 ratio + + cheat_caller_address(router.contract_address, RELAYER, span: CheatSpan::TargetCalls(1)); + router.swap(routes, from_amount, min_amount_out); + + // 6. Claim (epoch should be settled now) + // Offset factor defaults to WAD, so due_amount remains the same + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + let receivable = router.claim(new_nft_id); + + // Verify user received correct proportional amount: 100 * 200 / 100 = 200 + let expected_receivable = WAD * 200; + assert(receivable == expected_receivable, 'Receivable amount incorrect'); + + // Verify NFT is burned + let _pending_redeem_assetsrouter_erc721 = ERC721ABIDispatcher { contract_address: router.contract_address }; + // Should panic if trying to check owner of burned NFT, but we can check is_claimed + let request_info = router.new_nft_request_info(new_nft_id); + assert(request_info.is_claimed == true, 'NFT should be marked as claimed'); + + // Verify pool fully consumed, unsettled_swap_id advanced + assert(router.unsettled_swap_id() == 2, 'unsettled_swap_id != 2'); +} + +#[test] +fn test_claim_two_subscribes_one_swap_two_claims() { + let (vault, from_asset, to_asset, redeem_request, avnu_exchange, router) = set_up(); + + // 1. Two subscribes + let due_amount_1: u256 = WAD * 100; + let old_nft_id_1 = mint_and_redeem_old_nft_to_user(vault, USER1(), due_amount_1); + let due_amount_2: u256 = WAD * 200; + let old_nft_id_2 = mint_and_redeem_old_nft_to_user(vault, USER2(), due_amount_2); + + let erc721_dispatcher = ERC721ABIDispatcher { + contract_address: redeem_request.contract_address, + }; + + cheat_caller_address(redeem_request.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id_1); + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + let new_nft_id_1 = router.subscribe(old_nft_id_1, USER1()); + + cheat_caller_address(redeem_request.contract_address, USER2(), span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id_2); + cheat_caller_address(router.contract_address, USER2(), span: CheatSpan::TargetCalls(1)); + let new_nft_id_2 = router.subscribe(old_nft_id_2, USER2()); + + // 2. Fulfill both old NFTs + fulfill_old_nft(vault, from_asset, old_nft_id_1); + fulfill_old_nft(vault, from_asset, old_nft_id_2); + println!("fulfilled 1"); + + // 3. Mint to_asset to mock exchange + let to_asset_dispatcher = ERC20ABIDispatcher { contract_address: to_asset }; + cheat_caller_address(to_asset, OWNER(), span: CheatSpan::TargetCalls(1)); + to_asset_dispatcher.transfer(avnu_exchange.contract_address, WAD * 1000); + println!("minted to_asset"); + + // 4. One swap: 300 from → 600 to (2:1 ratio) + let routes: Array = array![]; + let from_asset_dispatcher = ERC20ABIDispatcher { contract_address: from_asset }; + let balance_from = from_asset_dispatcher.balance_of(router.contract_address); + println!("balance_from: {}", balance_from); + cheat_caller_address(router.contract_address, RELAYER, span: CheatSpan::TargetCalls(1)); + router.swap(routes, balance_from, WAD * 600); + println!("swapped"); + + // 6. Claim User 1: due = 100, should get 100 * 600 / 300 = 200 + // (no need to mock due_assets_from_id - it's stored in RequestInfo) + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + let receivable_1 = router.claim(new_nft_id_1); + assert(receivable_1 == WAD * 200, 'User 1 receivable incorrect'); + println!("claimed 1"); + + // Verify swap info updated correctly + let (from_rem, to_rem) = router.swap_info(1); + println!("from_rem: {}", from_rem); + println!("to_rem: {}", to_rem); + assert(from_rem == WAD * 200, 'Remaining from_amount incorrect'); + assert(to_rem == WAD * 400, 'Remaining to_amount incorrect'); + println!("claimed 2"); + // 7. Claim User 2: due = 200, should get 200 * 600 / 300 = 400 + // But since pool has remaining: 200 from, 400 to, user gets 400 + // (no need to mock due_assets_from_id - it's stored in RequestInfo) + + cheat_caller_address(router.contract_address, USER2(), span: CheatSpan::TargetCalls(1)); + let receivable_2 = router.claim(new_nft_id_2); + assert(receivable_2 == WAD * 400, 'User 2 receivable incorrect'); + + // Verify pool fully consumed + assert(router.unsettled_swap_id() == 2, 'unsettled_swap_id != 2'); +} + +#[test] +#[should_panic(expected: "Claim not allowed")] +fn test_claim_requires_epoch_settled() { + let (vault, from_asset, to_asset, redeem_request, avnu_exchange, router) = set_up(); + + // Subscribe to epoch 5 + let due_amount: u256 = WAD * 100; + let old_nft_id = mint_and_redeem_old_nft_to_user(vault, USER1(), due_amount); + + let erc721_dispatcher = ERC721ABIDispatcher { + contract_address: redeem_request.contract_address, + }; + cheat_caller_address(redeem_request.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id); + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + let new_nft_id = router.subscribe(old_nft_id, USER1()); + + // Fulfill old NFT + fulfill_old_nft(vault, from_asset, old_nft_id); + + // Transfer assets and swap (but not enough to settle epoch 5) + let from_asset_dispatcher = ERC20ABIDispatcher { contract_address: from_asset }; + cheat_caller_address(from_asset, OWNER(), span: CheatSpan::TargetCalls(1)); + from_asset_dispatcher.transfer(router.contract_address, WAD * 50); // Only 50, not enough for epoch 5 + + let to_asset_dispatcher = ERC20ABIDispatcher { contract_address: to_asset }; + cheat_caller_address(to_asset, OWNER(), span: CheatSpan::TargetCalls(1)); + to_asset_dispatcher.transfer(avnu_exchange.contract_address, WAD * 1000); + + let routes: Array = array![]; + cheat_caller_address(router.contract_address, RELAYER, span: CheatSpan::TargetCalls(1)); + router.swap(routes, WAD * 50, WAD * 100); // Swap 50, epoch 5 needs 100, so not fully settled + + // Attempt to claim - should fail because epoch not fully settled + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + router.claim(new_nft_id); // Should fail - epoch not settled +} + +// ============================================================================ +// 5. Unsubscribe Function Tests +// ============================================================================ + +#[test] +fn test_unsubscribe_original_nft_not_fulfilled_returns_nft() { + let (vault, _, _, redeem_request, _, router) = set_up(); + + // 1. Subscribe + let due_amount: u256 = WAD * 100; + let old_nft_id = mint_and_redeem_old_nft_to_user(vault, USER1(), due_amount); + + let erc721_dispatcher = ERC721ABIDispatcher { + contract_address: redeem_request.contract_address, + }; + cheat_caller_address(redeem_request.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id); + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + let new_nft_id = router.subscribe(old_nft_id, USER1()); + + // Verify old NFT is owned by router + let redeem_request_erc721 = ERC721ABIDispatcher { + contract_address: redeem_request.contract_address, + }; + assert(redeem_request_erc721.owner_of(old_nft_id) == router.contract_address, 'Old NFT owned by router'); + + // 2. Unsubscribe (original NFT not fulfilled) + // Use unsubscribe_for_nft since old NFT is not fulfilled + // Caller must own the NFT (USER1 already owns it) + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + router.unsubscribe_for_nft(new_nft_id, USER1()); + + // Verify old NFT returned to user + assert(redeem_request_erc721.owner_of(old_nft_id) == USER1(), 'Old NFT returned to user'); + + // Verify new NFT is burned and marked as unsubscribed + let request_info = router.new_nft_request_info(new_nft_id); + assert(request_info.unsubscribed == true, 'NFT marked as unsubscribed'); +} + +#[test] +fn test_unsubscribe_original_nft_fulfilled_but_not_swapped_returns_assets() { + let (vault, from_asset, _, redeem_request, _, router) = set_up(); + + // 1. Subscribe + let due_amount: u256 = WAD * 100; + let old_nft_id = mint_and_redeem_old_nft_to_user(vault, USER1(), due_amount); + + let erc721_dispatcher = ERC721ABIDispatcher { + contract_address: redeem_request.contract_address, + }; + cheat_caller_address(redeem_request.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id); + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + let new_nft_id = router.subscribe(old_nft_id, USER1()); + + // 2. Fulfill old NFT (burn it) + fulfill_old_nft(vault, from_asset, old_nft_id); + // Mark old NFT as fulfilled in router's storage + mark_old_nft_fulfilled(router.contract_address, old_nft_id); + println!("old_nft_id: {}", old_nft_id); + let from_asset_dispatcher = ERC20ABIDispatcher { contract_address: from_asset }; + + // Get initial user balance + let user_balance_before = from_asset_dispatcher.balance_of(USER1()); + + // 4. Unsubscribe (original NFT fulfilled but not swapped) + // Use unsubscribe_for_underlying since old NFT is fulfilled + // Caller must own the NFT (USER1 already owns it) + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + router.unsubscribe_for_underlying(new_nft_id, USER1()); + println!("new_nft_id: {}", new_nft_id); + + // Verify user received from_assets + let user_balance_after = from_asset_dispatcher.balance_of(USER1()); + println!("user_balance_before: {}", user_balance_before); + println!("user_balance_after: {}", user_balance_after); + let bal_remaining = ERC20ABIDispatcher { contract_address: from_asset }.balance_of(router.contract_address); + println!("bal_remaining: {}", bal_remaining); + assert(user_balance_after == user_balance_before + WAD * 100, 'User should receive from_assets'); + + // Verify new NFT is burned and marked as unsubscribed + let request_info = router.new_nft_request_info(new_nft_id); + assert(request_info.unsubscribed == true, 'NFT marked as unsubscribed'); +} + +#[test] +#[should_panic(expected: "Cannot unsubscribe: swaps have partially consumed assets")] +fn test_unsubscribe_original_nft_fulfilled_partially_swapped_reverts() { + let (vault, from_asset, to_asset, redeem_request, avnu_exchange, router) = set_up(); + + // 1. Subscribe + let due_amount: u256 = WAD * 100; + let old_nft_id = mint_and_redeem_old_nft_to_user(vault, USER1(), 100); + + let erc721_dispatcher = ERC721ABIDispatcher { + contract_address: redeem_request.contract_address, + }; + cheat_caller_address(redeem_request.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id); + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + let new_nft_id = router.subscribe(old_nft_id, USER1()); + + // 2. Fulfill old NFT + fulfill_old_nft(vault, from_asset, old_nft_id); + // Mark old NFT as fulfilled in router's storage + mark_old_nft_fulfilled(router.contract_address, old_nft_id); + + // 3. Transfer assets to router + let from_asset_dispatcher = ERC20ABIDispatcher { contract_address: from_asset }; + cheat_caller_address(from_asset, OWNER(), span: CheatSpan::TargetCalls(1)); + from_asset_dispatcher.transfer(router.contract_address, WAD * 100); + + // 4. Partial swap (swap 50 out of 100) + let to_asset_dispatcher = ERC20ABIDispatcher { contract_address: to_asset }; + cheat_caller_address(to_asset, OWNER(), span: CheatSpan::TargetCalls(1)); + to_asset_dispatcher.transfer(avnu_exchange.contract_address, WAD * 200); + + let routes: Array = array![]; + cheat_caller_address(router.contract_address, RELAYER, span: CheatSpan::TargetCalls(1)); + router.swap(routes, WAD * 50, WAD * 100); // Swap 50, leaving 50 remaining (epoch needs 100 total) + + // 5. Attempt unsubscribe - should revert because swaps have partially consumed + // Use unsubscribe_for_underlying since old NFT is fulfilled + // Caller must own the NFT (USER1 already owns it) + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + router.unsubscribe_for_underlying(new_nft_id, USER1()); +} + +#[test] +fn test_unsubscribe_second_user_before_swaps() { + let (vault, _, _, redeem_request, _, router) = set_up(); + + // 1. Two users subscribe + let due_amount_1: u256 = WAD * 100; + let old_nft_id_1 = mint_and_redeem_old_nft_to_user(vault, USER1(), due_amount_1); + let due_amount_2: u256 = WAD * 200; + let old_nft_id_2 = mint_and_redeem_old_nft_to_user(vault, USER2(), due_amount_2); + + let erc721_dispatcher = ERC721ABIDispatcher { + contract_address: redeem_request.contract_address, + }; + + cheat_caller_address(redeem_request.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id_1); + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + let new_nft_id_1 = router.subscribe(old_nft_id_1, USER1()); + + cheat_caller_address(redeem_request.contract_address, USER2(), span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id_2); + cheat_caller_address(router.contract_address, USER2(), span: CheatSpan::TargetCalls(1)); + let new_nft_id_2 = router.subscribe(old_nft_id_2, USER2()); + + // 2. User 2 unsubscribes (original NFT not fulfilled) + let redeem_request_erc721 = ERC721ABIDispatcher { + contract_address: redeem_request.contract_address, + }; + // Use unsubscribe_for_nft since old NFT is not fulfilled + // Caller must own the NFT (USER2 already owns it) + cheat_caller_address(router.contract_address, USER2(), span: CheatSpan::TargetCalls(1)); + router.unsubscribe_for_nft(new_nft_id_2, USER2()); + + // Verify User 2's old NFT returned + assert(redeem_request_erc721.owner_of(old_nft_id_2) == USER2(), 'User 2 NFT returned'); + + // Verify User 1's old NFT still owned by router + assert(redeem_request_erc721.owner_of(old_nft_id_1) == router.contract_address, 'User 1 NFT in router'); + + // Verify User 1 can still claim later (after swaps) + let request_info_1 = router.new_nft_request_info(new_nft_id_1); + assert(request_info_1.unsubscribed == false, 'User 1 not unsubscribed'); +} + +#[test] +fn test_unsubscribe_third_user_after_second_withdrawn() { + let (vault, _, _, redeem_request, _, router) = set_up(); + + // 1. Three users subscribe + let due_amount_1: u256 = WAD * 100; + let old_nft_id_1 = mint_and_redeem_old_nft_to_user(vault, USER1(), due_amount_1); + let due_amount_2: u256 = WAD * 200; + let old_nft_id_2 = mint_and_redeem_old_nft_to_user(vault, USER2(), due_amount_2); + let due_amount_3: u256 = WAD * 300; + let old_nft_id_3 = mint_and_redeem_old_nft_to_user(vault, 'USER3'.try_into().unwrap(), due_amount_3); + + let erc721_dispatcher = ERC721ABIDispatcher { + contract_address: redeem_request.contract_address, + }; + let user3: ContractAddress = 'USER3'.try_into().unwrap(); + + cheat_caller_address(redeem_request.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id_1); + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + let _new_nft_id_1 = router.subscribe(old_nft_id_1, USER1()); + + cheat_caller_address(redeem_request.contract_address, USER2(), span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id_2); + cheat_caller_address(router.contract_address, USER2(), span: CheatSpan::TargetCalls(1)); + let new_nft_id_2 = router.subscribe(old_nft_id_2, USER2()); + + cheat_caller_address(redeem_request.contract_address, user3, span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id_3); + cheat_caller_address(router.contract_address, user3, span: CheatSpan::TargetCalls(1)); + let new_nft_id_3 = router.subscribe(old_nft_id_3, user3); + + // 2. User 2 unsubscribes + // Use unsubscribe_for_nft since old NFT is not fulfilled + // Caller must own the NFT (USER2 already owns it) + cheat_caller_address(router.contract_address, USER2(), span: CheatSpan::TargetCalls(1)); + router.unsubscribe_for_nft(new_nft_id_2, USER2()); + + // 3. User 3 unsubscribes (should work even though User 2 withdrew) + let redeem_request_erc721 = ERC721ABIDispatcher { + contract_address: redeem_request.contract_address, + }; + // Use unsubscribe_for_nft since old NFT is not fulfilled + // Caller must own the NFT (user3 already owns it) + cheat_caller_address(router.contract_address, user3, span: CheatSpan::TargetCalls(1)); + router.unsubscribe_for_nft(new_nft_id_3, user3); + + // Verify User 3's old NFT returned + assert(redeem_request_erc721.owner_of(old_nft_id_3) == user3, 'User 3 NFT returned'); + + // Verify User 1's old NFT still owned by router + assert(redeem_request_erc721.owner_of(old_nft_id_1) == router.contract_address, 'User 1 NFT in router'); +} + +#[test] +fn test_unsubscribe_second_user_after_fulfillment_but_before_swaps() { + let (vault, from_asset, _, redeem_request, _, router) = set_up(); + + // 1. Two users subscribe + let due_amount_1: u256 = WAD * 100; + let old_nft_id_1 = mint_and_redeem_old_nft_to_user(vault, USER1(), due_amount_1); + + let due_amount_2: u256 = WAD * 200; + let old_nft_id_2 = mint_and_redeem_old_nft_to_user(vault, USER2(), due_amount_2); + + let erc721_dispatcher = ERC721ABIDispatcher { + contract_address: redeem_request.contract_address, + }; + + // User 1 subscribes + cheat_caller_address(redeem_request.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id_1); + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + let new_nft_id_1 = router.subscribe(old_nft_id_1, USER1()); + + cheat_caller_address(redeem_request.contract_address, USER2(), span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id_2); + cheat_caller_address(router.contract_address, USER2(), span: CheatSpan::TargetCalls(1)); + let new_nft_id_2 = router.subscribe(old_nft_id_2, USER2()); + + // 2. Fulfill both old NFTs + fulfill_old_nft(vault, from_asset, old_nft_id_1); + fulfill_old_nft(vault, from_asset, old_nft_id_2); + // Mark old NFTs as fulfilled in router's storage + mark_old_nft_fulfilled(router.contract_address, old_nft_id_1); + mark_old_nft_fulfilled(router.contract_address, old_nft_id_2); + + // 3. Transfer assets to router (300 total: 100 for user1, 200 for user2) + let from_asset_dispatcher = ERC20ABIDispatcher { contract_address: from_asset }; + cheat_caller_address(from_asset, OWNER(), span: CheatSpan::TargetCalls(1)); + from_asset_dispatcher.transfer(router.contract_address, WAD * 300); + + // Get User 2 balance before + let user2_balance_before = from_asset_dispatcher.balance_of(USER2()); + + // 4. User 2 unsubscribes (original NFT fulfilled but not swapped) + // Use unsubscribe_for_underlying since old NFT is fulfilled + // Caller must own the NFT (USER2 already owns it) + cheat_caller_address(router.contract_address, USER2(), span: CheatSpan::TargetCalls(1)); + router.unsubscribe_for_underlying(new_nft_id_2, USER2()); + + // Verify User 2 received from_assets (200) + let user2_balance_after = from_asset_dispatcher.balance_of(USER2()); + assert(user2_balance_after == user2_balance_before + WAD * 200, 'User 2 received 200'); + + // Verify User 1 can still claim later + let request_info_1 = router.new_nft_request_info(new_nft_id_1); + assert(request_info_1.unsubscribed == false, 'User 1 not unsubscribed'); +} + +#[test] +#[should_panic(expected: "NFT already withdrawn")] +fn test_unsubscribe_twice_reverts() { + let (vault, _, _, redeem_request, _, router) = set_up(); + + // Subscribe + let due_amount: u256 = WAD * 100; + let old_nft_id = mint_and_redeem_old_nft_to_user(vault, USER1(), due_amount); + + let erc721_dispatcher = ERC721ABIDispatcher { + contract_address: redeem_request.contract_address, + }; + cheat_caller_address(redeem_request.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id); + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + let new_nft_id = router.subscribe(old_nft_id, USER1()); + + // Unsubscribe first time + // Use unsubscribe_for_nft since old NFT is not fulfilled + // Caller must own the NFT (USER1 already owns it) + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + router.unsubscribe_for_nft(new_nft_id, USER1()); + + // Attempt unsubscribe second time - should revert + // Note: NFT is already burned, so we can't transfer it again + // But we can try to call unsubscribe again which should fail + // Use unsubscribe_for_nft since old NFT is not fulfilled + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + router.unsubscribe_for_nft(new_nft_id, USER1()); +} + +// ============================================================================ +// 6. Access Control & Setter Tests +// ============================================================================ + +#[test] +fn test_set_min_subscribe_amount_only_owner() { + let (_, _, _, _, _, router) = set_up(); + + // Owner can set min_subscribe_amount + cheat_caller_address(router.contract_address, OWNER(), span: CheatSpan::TargetCalls(1)); + router.set_min_subscribe_amount(WAD * 100); + + // Verify it was set (we can't directly read it, but we can test it works) + // by trying to subscribe with amount below minimum +} + +#[test] +#[should_panic(expected: ('Caller is missing role',))] +fn test_set_min_subscribe_amount_reverts_when_not_owner() { + let (_, _, _, _, _, router) = set_up(); + + // Non-owner attempts to set min_subscribe_amount + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + router.set_min_subscribe_amount(WAD * 100); +} + +#[test] +#[should_panic(expected: ('Caller is missing role',))] +fn test_set_integrator_fee_amount_bps_reverts_when_not_owner() { + let (_, _, _, _, _, router) = set_up(); + + // Non-owner attempts to set integrator_fee_amount_bps + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + router.set_integrator_fee_amount_bps(100); +} + +#[test] +#[should_panic(expected: "Invalid integrator fee amount")] +fn test_set_integrator_fee_amount_bps_reverts_on_invalid_fee() { + let (_, _, _, _, _, router) = set_up(); + + // Owner attempts to set fee > 500 bps (max allowed) + cheat_caller_address(router.contract_address, OWNER(), span: CheatSpan::TargetCalls(1)); + router.set_integrator_fee_amount_bps(501); // Exceeds max of 500 bps +} + +#[test] +fn test_set_integrator_fee_amount_bps_success() { + let (_, _, _, _, _, router) = set_up(); + + // Verify initial fee (set in set_up) + let initial_fee = router.integrator_fee_amount_bps(); + assert(initial_fee == 100, 'Initial fee should be 100 bps'); + + // Owner sets valid fee + cheat_caller_address(router.contract_address, OWNER(), span: CheatSpan::TargetCalls(1)); + router.set_integrator_fee_amount_bps(250); + + // Verify fee was updated + let new_fee = router.integrator_fee_amount_bps(); + assert(new_fee == 250, 'Fee should be 250 bps'); + + // Test edge case: set to max allowed (500 bps) + cheat_caller_address(router.contract_address, OWNER(), span: CheatSpan::TargetCalls(1)); + router.set_integrator_fee_amount_bps(500); + + let max_fee = router.integrator_fee_amount_bps(); + assert(max_fee == 500, 'Fee should be 500 bps'); +} + +#[test] +#[should_panic(expected: ('Caller is missing role',))] +fn test_set_integrator_fee_recipient_reverts_when_not_owner() { + let (_, _, _, _, _, router) = set_up(); + + // Non-owner attempts to set integrator_fee_recipient + let new_recipient: ContractAddress = 'NEW_RECIPIENT'.try_into().unwrap(); + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + router.set_integrator_fee_recipient(new_recipient); +} + +// ============================================================================ +// 7. Epoch Settlement & Sync Tests +// ============================================================================ + +#[test] +fn test_sync_settled_epochs() { + let (vault, from_asset, to_asset, redeem_request, avnu_exchange, router) = set_up(); + + // Subscribe to epoch 1 + let due_amount: u256 = WAD * 100; + let old_nft_id = mint_and_redeem_old_nft_to_user(vault, USER1(), due_amount); + + let erc721_dispatcher = ERC721ABIDispatcher { + contract_address: redeem_request.contract_address, + }; + cheat_caller_address(redeem_request.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id); + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + let _new_nft_id = router.subscribe(old_nft_id, USER1()); + + // report once to skip epoch 0 + report(vault, from_asset); + + // Fulfill old NFT + fulfill_old_nft(vault, from_asset, old_nft_id); + + let to_asset_dispatcher = ERC20ABIDispatcher { contract_address: to_asset }; + cheat_caller_address(to_asset, OWNER(), span: CheatSpan::TargetCalls(1)); + to_asset_dispatcher.transfer(avnu_exchange.contract_address, WAD * 1000); + + let routes: Array = array![]; + cheat_caller_address(router.contract_address, RELAYER, span: CheatSpan::TargetCalls(1)); + router.swap(routes, WAD * 100, WAD * 200); + + // Sync settled epochs + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + router.sync_settled_epochs(10); // Check up to 10 epochs + + // Verify last_settled_epoch updated + println!("last_settled_epoch: {}", router.last_settled_epoch()); + assert(router.last_settled_epoch() == 1, 'last_settled_epoch should be 1'); +} + +#[test] +#[should_panic(expected: ('Pausable: paused',))] +fn test_sync_settled_epochs_reverts_when_paused() { + let (_, _, _, _, _, router) = set_up(); + + // Pause contract + cheat_caller_address(router.contract_address, OWNER(), span: CheatSpan::TargetCalls(1)); + router.pause(); + + // Attempt sync + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + router.sync_settled_epochs(10); +} + +#[test] +fn test_report_updates_offset_factor_for_currently_handled_epoch() { + let (vault, _, _, redeem_request, _, router) = set_up(); + + let vault_dispatcher = IVaultDispatcher { contract_address: vault.contract_address }; + + // Initial state: no epochs handled yet + let handled_epochs_before = vault.handled_epoch_len(); + assert(handled_epochs_before == 0, 'Should start with 0'); + + // Grant RELAYER role to router + let access_control = IAccessControlDispatcher { + contract_address: vault.contract_address, + }; + cheat_caller_address(vault.contract_address, OWNER(), span: CheatSpan::TargetCalls(1)); + access_control.grant_role(Vault::ORACLE_ROLE, router.contract_address); + + // Subscribe to epoch 0 (before any report) + let due_amount: u256 = WAD * 100; + let old_nft_id = mint_and_redeem_old_nft_to_user(vault, USER1(), due_amount); + + let erc721_dispatcher = ERC721ABIDispatcher { + contract_address: redeem_request.contract_address, + }; + cheat_caller_address(redeem_request.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id); + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + let _new_nft_id = router.subscribe(old_nft_id, USER1()); + + // Verify epoch 0 offset factor is not set (defaults to 0, which means WAD when used) + let offset_before_report = router.get_epoch_offset(0); + assert(offset_before_report == 1000000000000000000, 'Offset should be 0'); + + // Call report on vault to handle epoch 0 + // increase timestamp, else report fails + let now = get_block_timestamp(); + start_cheat_block_timestamp_global(now + 3600); // 1 hour + + // runs epoch from 1 to 1 (skips epoch 0) + cheat_caller_address(router.contract_address, RELAYER, span: CheatSpan::TargetCalls(1)); + router.report(0); + + // After vault report, handled_epoch_len should be 1 (epoch 0 is now handled) + let handled_epochs_after_vault = vault.handled_epoch_len(); + assert(handled_epochs_after_vault == 1, 'Epoch 0 should be handled'); + + // advance epoch + start_cheat_block_timestamp_global(now + (3600 * 2)); // 2 hour + // Now call router.report() which should update offset factor for epoch 0 + // The bug: it starts from handled_epochs_before + 1 = 1 + 1 = 2, runs till 2, skipping epoch 1 + cheat_caller_address(router.contract_address, RELAYER, span: CheatSpan::TargetCalls(1)); + let aum = vault.aum(); + router.report(aum * 100001/100000); + + // request withdrawal (i.e. mint nft) && subscribe to epoch 1 + let old_nft_id = mint_and_redeem_old_nft_to_user(vault, USER1(), WAD * 100); + cheat_caller_address(redeem_request.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + erc721_dispatcher.approve(router.contract_address, old_nft_id); + cheat_caller_address(router.contract_address, USER1(), span: CheatSpan::TargetCalls(1)); + let _new_nft_id = router.subscribe(old_nft_id, USER1()); + + start_cheat_block_timestamp_global(now + (3600 * 3)); // 3 hour + // Now call router.report() which should update offset factor for epoch 0 + // The bug: it starts from handled_epochs_before + 1 = 2 + 1 = 3, runs till 3, skipping epoch 2 + cheat_caller_address(router.contract_address, RELAYER, span: CheatSpan::TargetCalls(1)); + let aum = vault.aum(); + router.report(aum * (100000 - 1)/100000); // loss of 1 basis point + + // Verify epoch 0 offset factor was updated + // If the bug exists, offset will still be 0 (not updated) + // If fixed, offset should be calculated based on the ratio + let offset_after_report = router.get_epoch_offset(2); + + // The bug: offset_after_report will be 0 because epoch 0 was skipped + // After fix: offset_after_report should be calculated if conditions are met + // For now, we test that the bug exists by checking offset is still 0 + // After fixing, this assertion should check that offset was calculated + assert(offset_after_report != WAD && offset_after_report != 0, 'Offset factor not right'); +} \ No newline at end of file diff --git a/packages/vault/src/test/units/vault.cairo b/packages/vault/src/test/units/vault.cairo index c02ad023..cd8ac5e6 100644 --- a/packages/vault/src/test/units/vault.cairo +++ b/packages/vault/src/test/units/vault.cairo @@ -216,14 +216,15 @@ fn test_set_report_delay_not_owner() { vault.set_report_delay(Vault::MIN_REPORT_DELAY); } -#[test] -#[should_panic(expected: "Invalid report delay")] -fn test_set_report_delay_invalid_delay() { - let (_, vault, _) = set_up(); - let invalid_delay = Vault::MIN_REPORT_DELAY - 1; - cheat_caller_address_once(vault.contract_address, OWNER()); - vault.set_report_delay(invalid_delay); -} +// Not needed because MIN_REPORT_DELAY is 0 +// #[test] +// #[should_panic(expected: "Invalid report delay")] +// fn test_set_report_delay_invalid_delay() { +// let (_, vault, _) = set_up(); +// let invalid_delay = Vault::MIN_REPORT_DELAY - 1; +// cheat_caller_address_once(vault.contract_address, OWNER()); +// vault.set_report_delay(invalid_delay); +// } #[test] fn test_set_report_delay_success() { diff --git a/packages/vault/src/test/utils.cairo b/packages/vault/src/test/utils.cairo index bd3dc487..70973b50 100644 --- a/packages/vault/src/test/utils.cairo +++ b/packages/vault/src/test/utils.cairo @@ -116,7 +116,7 @@ pub fn deploy_counter() -> (ICounterDispatcher, ClassHash) { pub fn deploy_erc20_mock() -> ContractAddress { let erc20 = declare("Erc20Mock").unwrap().contract_class(); let mut calldata = ArrayTrait::new(); - (WAD * 100).serialize(ref calldata); + (WAD * 1000).serialize(ref calldata); OWNER().serialize(ref calldata); OWNER().serialize(ref calldata); let (erc20_address, _) = erc20.deploy(@calldata).unwrap(); diff --git a/packages/vault/src/vault/vault.cairo b/packages/vault/src/vault/vault.cairo index 1bb806df..bcf77ce0 100644 --- a/packages/vault/src/vault/vault.cairo +++ b/packages/vault/src/vault/vault.cairo @@ -64,7 +64,7 @@ pub mod Vault { pub const MAX_REDEEM_FEE: u256 = WAD / 1000; // 0.1% - maximum redemption fee pub const MAX_MANAGEMENT_FEE: u256 = WAD / 50; // 2% - maximum annual management fee pub const MAX_PERFORMANCE_FEE: u256 = WAD / 5; // 20% - maximum performance fee - pub const MIN_REPORT_DELAY: u64 = HOUR; // 1 hour - minimum report delay + pub const MIN_REPORT_DELAY: u64 = 0; // 0 seconds - minimum report delay // --- Time Constants --- pub const MIN: u64 = 60; // Seconds in a minute diff --git a/packages/vault_allocator/src/lib.cairo b/packages/vault_allocator/src/lib.cairo index 5701d8b5..27a8ba77 100644 --- a/packages/vault_allocator/src/lib.cairo +++ b/packages/vault_allocator/src/lib.cairo @@ -80,6 +80,7 @@ pub mod mocks { pub mod erc20; pub mod erc4626; pub mod vault; + pub mod mock_avnu_exchange; } #[cfg(test)] diff --git a/packages/vault_allocator/src/mocks/mock_avnu_exchange.cairo b/packages/vault_allocator/src/mocks/mock_avnu_exchange.cairo new file mode 100644 index 00000000..eeecee3e --- /dev/null +++ b/packages/vault_allocator/src/mocks/mock_avnu_exchange.cairo @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 Starknet Vault Kit +// Licensed under the MIT License. See LICENSE file for details. + +use starknet::{ContractAddress, get_caller_address, get_contract_address}; +use openzeppelin::interfaces::erc20::{ERC20ABIDispatcher, ERC20ABIDispatcherTrait}; +use vault_allocator::decoders_and_sanitizers::decoder_custom_types::Route; + +#[starknet::interface] +pub trait IAvnuExchange { + fn multi_route_swap( + ref self: T, + sell_token_address: ContractAddress, + sell_token_amount: u256, + buy_token_address: ContractAddress, + buy_token_amount: u256, + buy_token_min_amount: u256, + beneficiary: ContractAddress, + integrator_fee_amount_bps: u128, + integrator_fee_recipient: ContractAddress, + routes: Array, + ) -> bool; + fn swap_exact_token_to( + ref self: T, + sell_token_address: ContractAddress, + sell_token_amount: u256, + sell_token_max_amount: u256, + buy_token_address: ContractAddress, + buy_token_amount: u256, + beneficiary: ContractAddress, + routes: Array, + ) -> bool; +} + +#[starknet::contract] +pub mod MockAvnuExchange { + use starknet::{ContractAddress, get_caller_address, get_contract_address}; + use openzeppelin::interfaces::erc20::{ERC20ABIDispatcher, ERC20ABIDispatcherTrait}; + use vault_allocator::decoders_and_sanitizers::decoder_custom_types::Route; + use super::IAvnuExchange; + + #[storage] + struct Storage {} + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + Swapped: Swapped, + } + + #[derive(Drop, starknet::Event)] + struct Swapped { + pub sell_token: ContractAddress, + pub sell_amount: u256, + pub buy_token: ContractAddress, + pub buy_amount: u256, + pub beneficiary: ContractAddress, + } + + #[constructor] + fn constructor(ref self: ContractState) { + // Empty constructor + } + + #[generate_trait] + impl InternalImpl of InternalTrait { + fn execute_swap( + ref self: ContractState, + sell_token_address: ContractAddress, + sell_token_amount: u256, + buy_token_address: ContractAddress, + buy_token_min_amount: u256, + beneficiary: ContractAddress, + ) { + let caller = get_caller_address(); + let this = get_contract_address(); + + // Transfer sell_token_amount from caller (router) to this contract + let sell_token_dispatcher = ERC20ABIDispatcher { + contract_address: sell_token_address, + }; + sell_token_dispatcher.transfer_from(caller, this, sell_token_amount); + + // Transfer buy_token_min_amount to beneficiary (router) + let buy_token_dispatcher = ERC20ABIDispatcher { + contract_address: buy_token_address, + }; + buy_token_dispatcher.transfer(beneficiary, buy_token_min_amount); + + // Emit event + self.emit(Swapped { + sell_token: sell_token_address, + sell_amount: sell_token_amount, + buy_token: buy_token_address, + buy_amount: buy_token_min_amount, + beneficiary, + }); + } + } + + #[abi(embed_v0)] + impl MockAvnuExchangeImpl of IAvnuExchange { + /// Mock implementation of multi_route_swap + /// Transfers sell_token_amount from caller and transfers buy_token_min_amount to beneficiary + fn multi_route_swap( + ref self: ContractState, + sell_token_address: ContractAddress, + sell_token_amount: u256, + buy_token_address: ContractAddress, + buy_token_amount: u256, + buy_token_min_amount: u256, + beneficiary: ContractAddress, + integrator_fee_amount_bps: u128, + integrator_fee_recipient: ContractAddress, + routes: Array, + ) -> bool { + InternalImpl::execute_swap( + ref self, + sell_token_address, + sell_token_amount, + buy_token_address, + buy_token_min_amount, + beneficiary, + ); + true + } + + fn swap_exact_token_to( + ref self: ContractState, + sell_token_address: ContractAddress, + sell_token_amount: u256, + sell_token_max_amount: u256, + buy_token_address: ContractAddress, + buy_token_amount: u256, + beneficiary: ContractAddress, + routes: Array, + ) -> bool { + // Not used in redemption router, but required by interface + false + } + } +} + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..cfc1cc3a --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,13 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@strkfarm/sdk': + specifier: link:../sdk-ts + version: link:../sdk-ts