From 3577ae5d1bcff0833c8386bd1666030590752937 Mon Sep 17 00:00:00 2001 From: Nicholas Addison Date: Tue, 21 Jul 2026 15:38:12 +1000 Subject: [PATCH 1/2] add propose_vault_strategy_moves actions for ethereum and base --- contracts/README.md | 5 + contracts/dev.env | 2 + contracts/docs/ACTIONS.md | 50 ++ contracts/migrations/seed_schedules.sql | 2 + contracts/package.json | 3 + contracts/pnpm-lock.yaml | 188 +++++ .../actions/proposeVaultStrategyMoves.ts | 294 ++++++++ contracts/tasks/lib/safeProposal.ts | 317 +++++++++ contracts/tasks/lib/vaultStrategyMoves.ts | 655 ++++++++++++++++++ contracts/test/tasks/vault-strategy-moves.js | 305 ++++++++ 10 files changed, 1821 insertions(+) create mode 100644 contracts/tasks/actions/proposeVaultStrategyMoves.ts create mode 100644 contracts/tasks/lib/safeProposal.ts create mode 100644 contracts/tasks/lib/vaultStrategyMoves.ts create mode 100644 contracts/test/tasks/vault-strategy-moves.js diff --git a/contracts/README.md b/contracts/README.md index 429f9124e7..33071274c5 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -316,6 +316,11 @@ pnpm hardhat healthcheck --network mainnet Signer construction (KMS via `utils/signersNoHardhat.js`, `DEPLOYER_PK` / `GOVERNOR_PK` fallbacks, `IMPERSONATE`, Defender) stays exactly as described in the sections above. The library only handles the nonce wrap; it does not construct signers here. +Actions that propose transactions through the Safe Transaction Service require +`SAFE_API_KEY`. The active Talos signer must be registered separately on each +chain as a delegate for the target Safe. A delegate can submit a proposal but +does not provide an owner confirmation or reduce the Safe threshold. + ### Defender Relayer Open Zeppelin's [Defender](https://defender.openzeppelin.com/) product has a [Relayer](https://docs.openzeppelin.com/defender/v2/manage/relayers) service that is a managed wallet. It handles the nonce, gas, signing and sending of transactions. diff --git a/contracts/dev.env b/contracts/dev.env index 0e8a7e2aae..fab3ed0b60 100644 --- a/contracts/dev.env +++ b/contracts/dev.env @@ -70,6 +70,8 @@ TENDERLY_ACCESS_TOKEN= # ACTION_API_BEARER_TOKEN=test-token # RUNNER_BASE_URL=http://origin-dollar:8080 # DATABASE_URL=postgresql://nonce:nonce@localhost:5432/nonce_queue +# Safe Transaction Service API key used by Safe-proposal actions +# SAFE_API_KEY= # Automated actions: Postgres nonce queue lock timeout in seconds (0 = wait forever). # If lock acquisition exceeds this timeout, the tx send fails and the action exits non-zero. diff --git a/contracts/docs/ACTIONS.md b/contracts/docs/ACTIONS.md index 61f8669499..02dacf3314 100644 --- a/contracts/docs/ACTIONS.md +++ b/contracts/docs/ACTIONS.md @@ -100,5 +100,55 @@ each run (see notes in `seed_schedules.sql`). | `stakeValidator` | Convert WETH to ETH and deposit to a validator from the Compounding Staking Strategy | | `removeValidator` | Remove a registered or exited compounding validator from the SSV cluster | | `ousdRebalancer` | Plan and execute OUSD strategy rebalancing via the RebalancerModule | +| `proposeVaultStrategyMoves` | Simulate and propose ordered OUSD/OETH strategy movements to the Strategist 2/8 Safe | | `queueGovernorSixProposal` | Queue a GovernorSix proposal (`--propid`) | | `executeGovernorSixProposal` | Execute a GovernorSix proposal (`--propid`) | + +## Manual / on-demand — Base + +| Action | Description | +| --------------------------- | ------------------------------------------------------------------------------------ | +| `proposeVaultStrategyMoves` | Simulate and propose ordered SuperOETH strategy movements to the Strategist 2/8 Safe | + +### Vault strategy proposal parameters + +Talos locks the Hardhat `--network` option, so there are two disabled manual +schedules: `propose_vault_strategy_moves_mainnet` for Ethereum and +`propose_vault_strategy_moves_base` for Base. Before selecting "Run now", edit +`--vault` and `--moves` on the Ethereum schedule, or `--moves` on the Base +schedule. The underlying Hardhat action remains shared across both chains. + +`proposeVaultStrategyMoves` accepts ordered, semicolon-separated movements. A +strategy can be a deployment name or address: + +```sh +pnpm hardhat proposeVaultStrategyMoves \ + --network mainnet \ + --vault OUSD \ + --moves "withdraw:OUSDMorphoV2StrategyProxy:500000;deposit:OUSDCurveAMOProxy:250000" +``` + +Supported operations are `deposit::`, +`withdraw::`, and `withdrawAll:`. Amounts are human +units of the Vault's backing asset. Operations execute in the supplied order. + +By default the action starts a temporary Hardhat fork, executes +`rebase -> snapshot -> movements`, derives `expectedProfit` and +`expectedVaultChange`, validates `checkDelta`, and then estimates the completed +Safe MultiSend before proposing it. The atomic proposal is always +`rebase -> snapshot -> movements -> checkDelta`. + +- `--skip-fork` skips the local fork and requires both `--expected-profit` and + `--expected-vault-change`. +- `--skip-estimation` skips only the final Safe estimation. +- `--dryrun` runs all enabled checks without signing or proposing. +- `--nonce ` targets an unexecuted Safe nonce so a pending proposal can be + replaced. Without it, the next available Safe nonce is used. +- `--profit-variance` and `--vault-change-variance` override the defaults: + OUSD `100/100`, OETH `1/1`, and SuperOETH `1/10`. + +The runner requires `SAFE_API_KEY`. Its active KMS or Defender signer must also +be registered, by a Safe owner, as a Safe Transaction Service delegate scoped +to `0x4FF1b9D9ba8558F5EAfCec096318eA0d8b541971` on both Ethereum and Base. +Delegation only permits proposal submission; it does not count toward the +Safe's 2/8 owner confirmations. A Safe module is not used. diff --git a/contracts/migrations/seed_schedules.sql b/contracts/migrations/seed_schedules.sql index 71142ba2b9..117f579d37 100644 --- a/contracts/migrations/seed_schedules.sql +++ b/contracts/migrations/seed_schedules.sql @@ -55,6 +55,8 @@ INSERT INTO schedules (product, name, command, cron_expr, timezone, enabled, not ('origin-dollar', 'module_rebase_base', 'cd /app && pnpm hardhat permissionedRebase --network base', '15 10,22 * * *', 'UTC', false, NULL), ('origin-dollar', 'module_rebase_sonic', 'cd /app && pnpm hardhat permissionedRebase --network sonic', '15 10,22 * * *', 'UTC', false, NULL), ('origin-dollar', 'ousd_rebalancer', 'cd /app && pnpm hardhat ousdRebalancer --network mainnet', '0 0 1 1 *', 'UTC', false, 'Manual: Run now to rebalance OUSD Morpho strategies'), +('origin-dollar', 'propose_vault_strategy_moves_mainnet', 'cd /app && pnpm hardhat proposeVaultStrategyMoves --network mainnet --vault OUSD --moves "withdrawAll:REPLACE_WITH_STRATEGY"', '0 0 1 1 *', 'UTC', false, 'Manual Ethereum launcher: replace vault and moves before Run now. Proposes a Strategist Safe transaction; never enable this schedule.'), +('origin-dollar', 'propose_vault_strategy_moves_base', 'cd /app && pnpm hardhat proposeVaultStrategyMoves --network base --vault SuperOETH --moves "withdrawAll:REPLACE_WITH_STRATEGY"', '0 0 1 1 *', 'UTC', false, 'Manual Base launcher: replace moves before Run now. Proposes a Strategist Safe transaction; never enable this schedule.'), ('origin-dollar', 'queue_proposal', 'cd /app && pnpm hardhat queueGovernorSixProposal --network mainnet', '0 0 1 1 *', 'UTC', false, 'Manual: add --propid then Run now'), ('origin-dollar', 'execute_proposal', 'cd /app && pnpm hardhat executeGovernorSixProposal --network mainnet', '0 0 1 1 *', 'UTC', false, 'Manual: add --propid then Run now') ON CONFLICT (product, name) DO NOTHING; diff --git a/contracts/package.json b/contracts/package.json index b0d4ebbaf0..c6ce8c2513 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -145,6 +145,9 @@ "@lodestar/state-transition": "^1.41.0", "@lodestar/types": "^1.41.0", "@lodestar/utils": "^1.41.0", + "@safe-global/api-kit": "^5.0.1", + "@safe-global/protocol-kit": "^8.0.3", + "@safe-global/types-kit": "^4.0.1", "@talos/client": "github:oplabs/talos#8f15dbbdc96a4d3cabf932d41e016779c0946266&path:packages/client", "@types/pg": "^8.20.0", "origin-morpho-utils": "^0.1.1", diff --git a/contracts/pnpm-lock.yaml b/contracts/pnpm-lock.yaml index 6eb2a29523..c1c02e501e 100644 --- a/contracts/pnpm-lock.yaml +++ b/contracts/pnpm-lock.yaml @@ -37,6 +37,15 @@ importers: '@lodestar/utils': specifier: ^1.41.0 version: 1.41.0 + '@safe-global/api-kit': + specifier: ^5.0.1 + version: 5.0.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@safe-global/protocol-kit': + specifier: ^8.0.3 + version: 8.0.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@safe-global/types-kit': + specifier: ^4.0.1 + version: 4.0.1(typescript@5.9.3)(zod@3.25.76) '@talos/client': specifier: github:oplabs/talos#8f15dbbdc96a4d3cabf932d41e016779c0946266&path:packages/client version: git+https://git@github.com:oplabs/talos.git#8f15dbbdc96a4d3cabf932d41e016779c0946266&path:packages/client(@types/pg@8.20.0)(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) @@ -1485,6 +1494,12 @@ packages: resolution: {integrity: sha512-m6iorjyhPK9ow5/trNs7qsBC/SOzJCO51pvvAF2W9nOiZ1t0RtCd+rlRmRmlWTv4M33V0wzIUeamJ2BPbzgUXA==} hasBin: true + '@peculiar/asn1-schema@2.8.0': + resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1661,6 +1676,22 @@ packages: cpu: [x64] os: [win32] + '@safe-global/api-kit@5.0.1': + resolution: {integrity: sha512-nbkSjrRTZh+LlD7e0A4fujKgLZnU5262e1iYTWsGx4ft6oo5kTAxc5pY0icls5PjmZI0UkMhYIv5izeBP7bcWg==} + + '@safe-global/protocol-kit@8.0.3': + resolution: {integrity: sha512-XKtpfalQQXzfcLq3BNcbCfqJt6ScdL6AZNsefxVSEujrPSY19K+GWmX3yHskbPE8Z9pI07OtJOOpypfLhsFr8Q==} + + '@safe-global/safe-deployments@1.37.59': + resolution: {integrity: sha512-y1eAviyDJARMqwXctqXylsBGqNkeFIq/q2XJlmkVZ9vSwfQBP31a33sUZkbmwwJuomv0/Yrl04YNDQ2C8CaGWw==} + engines: {node: '>=22.0.0', pnpm: '>=10.16.0'} + + '@safe-global/safe-modules-deployments@3.0.7': + resolution: {integrity: sha512-XTloEuDvKBQJCKoySg1Y+NMdVFJWxwhH2fSaziT8R4vIbuEyQYWW9vj4z92+hHPXCBBh0cyi/MBXW+HYjYroew==} + + '@safe-global/types-kit@4.0.1': + resolution: {integrity: sha512-zmIYyAH9mcBcqHszPgcfNjOJYuPSvWCcc/f8zeznh7N1HSA7jEoFErO06O4QDfwAAS4aEyoyjhPyfHbVYRheZg==} + '@scure/base@1.1.9': resolution: {integrity: sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==} @@ -2703,6 +2734,10 @@ packages: asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + asn1js@3.0.10: + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + engines: {node: '>=12.0.0'} + assert-plus@1.0.0: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} @@ -5118,6 +5153,14 @@ packages: typescript: optional: true + ox@0.14.30: + resolution: {integrity: sha512-LI11uu+8iiM1B3CLckgd++YF1a0A2k5wDoM9ZeQMiL21BOzQs6L//BLS6hb1HSEKCyycdDIQLsVQx9MjpcC0hA==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + p-cancelable@1.1.0: resolution: {integrity: sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==} engines: {node: '>=6'} @@ -5406,6 +5449,13 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.5: + resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} + engines: {node: '>=16.0.0'} + qs@6.14.0: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} @@ -5664,6 +5714,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -6291,6 +6346,14 @@ packages: typescript: optional: true + viem@2.55.1: + resolution: {integrity: sha512-sajvEmONS3dEDFh0jKXGugTU8ImyGoIV3sEHSWNRhgkH484v/+6DjZAplRhcdeqNV/VBxOs/l3raOG4NaXvX9A==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + web3-bzz@1.10.4: resolution: {integrity: sha512-ZZ/X4sJ0Uh2teU9lAGNS8EjveEppoHNQiKlOXAjedsrdWuaMErBPdLQjXfcrYvN6WM6Su9PMsAxf3FXXZ+HwQw==} engines: {node: '>=8.0.0'} @@ -6598,6 +6661,18 @@ packages: utf-8-validate: optional: true + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xhr-request-promise@0.1.3: resolution: {integrity: sha512-YUBytBsuwgitWtdRzXDDkWAXzhdGB8bYm0sSzMPZT7Z2MBjMSTHFsyCT1yCRATY+XC69DUrQraRAEgcoCRaIPg==} @@ -9326,6 +9401,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@peculiar/asn1-schema@2.8.0': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + optional: true + '@pkgjs/parseargs@0.11.0': optional: true @@ -9470,6 +9557,49 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.54.0': optional: true + '@safe-global/api-kit@5.0.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + dependencies: + '@safe-global/protocol-kit': 8.0.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@safe-global/types-kit': 4.0.1(typescript@5.9.3)(zod@3.25.76) + node-fetch: 2.7.0 + viem: 2.55.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + - zod + + '@safe-global/protocol-kit@8.0.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + dependencies: + '@safe-global/safe-deployments': 1.37.59 + '@safe-global/safe-modules-deployments': 3.0.7 + '@safe-global/types-kit': 4.0.1(typescript@5.9.3)(zod@3.25.76) + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + semver: 7.8.5 + viem: 2.55.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + optionalDependencies: + '@noble/curves': 1.9.1 + '@peculiar/asn1-schema': 2.8.0 + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + - zod + + '@safe-global/safe-deployments@1.37.59': + dependencies: + semver: 7.8.5 + + '@safe-global/safe-modules-deployments@3.0.7': {} + + '@safe-global/types-kit@4.0.1(typescript@5.9.3)(zod@3.25.76)': + dependencies: + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + transitivePeerDependencies: + - typescript + - zod + '@scure/base@1.1.9': {} '@scure/base@1.2.6': {} @@ -10941,6 +11071,13 @@ snapshots: dependencies: safer-buffer: 2.1.2 + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + optional: true + assert-plus@1.0.0: {} assert@2.1.0: @@ -13061,6 +13198,10 @@ snapshots: dependencies: ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + isows@1.0.7(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)): + dependencies: + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + isstream@0.1.2: {} jackspeak@3.4.3: @@ -13674,6 +13815,21 @@ snapshots: transitivePeerDependencies: - zod + ox@0.14.30(typescript@5.9.3)(zod@3.25.76): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + p-cancelable@1.1.0: {} p-cancelable@2.1.1: {} @@ -13930,6 +14086,14 @@ snapshots: punycode@2.3.1: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + optional: true + + pvutils@1.1.5: + optional: true + qs@6.14.0: dependencies: side-channel: 1.1.0 @@ -14219,6 +14383,8 @@ snapshots: semver@7.7.3: {} + semver@7.8.5: {} + send@0.19.2: dependencies: debug: 2.6.9 @@ -15012,6 +15178,23 @@ snapshots: - utf-8-validate - zod + viem@2.55.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + isows: 1.0.7(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + ox: 0.14.30(typescript@5.9.3)(zod@3.25.76) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + web3-bzz@1.10.4(bufferutil@4.1.0)(utf-8-validate@5.0.10): dependencies: '@types/node': 12.20.55 @@ -15524,6 +15707,11 @@ snapshots: bufferutil: 4.1.0 utf-8-validate: 5.0.10 + ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 5.0.10 + xhr-request-promise@0.1.3: dependencies: xhr-request: 1.1.0 diff --git a/contracts/tasks/actions/proposeVaultStrategyMoves.ts b/contracts/tasks/actions/proposeVaultStrategyMoves.ts new file mode 100644 index 0000000000..de51b72a5c --- /dev/null +++ b/contracts/tasks/actions/proposeVaultStrategyMoves.ts @@ -0,0 +1,294 @@ +/// + +import { Contract } from "ethers"; +import { formatUnits, getAddress } from "ethers/lib/utils"; +import { types } from "hardhat/config"; + +import addresses from "../../utils/addresses"; +import { action } from "../lib/action"; +import { + assertNonceStillAvailable, + assertRegisteredDelegate, + createSafeClients, + createSafeTransaction, + estimateSafeTransaction, + findIdenticalProposal, + proposeSafeTransaction, + resolveNonce, + safeTransactionUrl, + toMetaTransactions, +} from "../lib/safeProposal"; +import { + TOKEN_ABI, + VAULT_ABI, + buildBatchCalls, + getVaultConfig, + parseMoves, + resolveCheckerValues, + resolveMoves, + runLocalForkSimulation, +} from "../lib/vaultStrategyMoves"; + +action({ + name: "proposeVaultStrategyMoves", + description: + "Simulate and propose Strategist Safe deposits, withdrawals, and rebalances wrapped by the Vault Value Checker", + chains: [1, 8453], + params: (t) => { + t.addParam("vault", "OUSD, OETH, or SuperOETH", undefined, types.string); + t.addParam( + "moves", + "Semicolon-separated deposit, withdraw, and withdrawAll operations", + undefined, + types.string + ); + t.addOptionalParam( + "nonce", + "Exact Safe nonce to use instead of the next available nonce", + undefined, + types.int + ); + t.addOptionalParam( + "expectedProfit", + "Signed 18-decimal Value Checker expected profit override", + undefined, + types.string + ); + t.addOptionalParam( + "profitVariance", + "Unsigned 18-decimal Value Checker profit variance override", + undefined, + types.string + ); + t.addOptionalParam( + "expectedVaultChange", + "Signed 18-decimal Value Checker expected vault change override", + undefined, + types.string + ); + t.addOptionalParam( + "vaultChangeVariance", + "Unsigned 18-decimal Value Checker vault change variance override", + undefined, + types.string + ); + t.addFlag( + "skipFork", + "Skip the local fork; expectedProfit and expectedVaultChange become required" + ); + t.addFlag("skipEstimation", "Skip final Safe transaction estimation"); + t.addFlag("dryrun", "Validate and simulate without proposing"); + }, + run: async ({ signer, chainId, log, args }) => { + const provider = signer.provider!; + const config = getVaultConfig(args.vault, chainId); + const safeAddress = getAddress(addresses.multichainStrategist); + const apiKey = process.env.SAFE_API_KEY; + if (!apiKey) throw new Error("SAFE_API_KEY is required"); + + const deploymentRegistry = (hre as any).deployments; + const [vaultDeployment, checkerDeployment] = await Promise.all([ + deploymentRegistry.getOrNull(config.vaultDeployment), + deploymentRegistry.getOrNull(config.checkerDeployment), + ]); + if (!vaultDeployment) { + throw new Error( + `Missing ${config.vaultDeployment} deployment on the selected network` + ); + } + if (!checkerDeployment) { + throw new Error( + `Missing ${config.checkerDeployment} deployment on the selected network` + ); + } + const vaultAddress = getAddress(vaultDeployment.address); + const checkerAddress = getAddress(checkerDeployment.address); + const vault = new Contract(vaultAddress, VAULT_ABI, provider); + const [assetRaw, oTokenRaw, strategistRaw, activeStrategies] = + await Promise.all([ + vault.asset(), + vault.oToken(), + vault.strategistAddr(), + vault.getAllStrategies(), + ]); + const asset = getAddress(assetRaw); + const oToken = getAddress(oTokenRaw); + const vaultStrategist = getAddress(strategistRaw); + if (vaultStrategist !== safeAddress) { + throw new Error( + `${config.name} vault strategist is ${vaultStrategist}, not configured Safe ${safeAddress}` + ); + } + const assetToken = new Contract(asset, TOKEN_ABI, provider); + const assetDecimals = Number(await assetToken.decimals()); + + const parsedMoves = parseMoves(args.moves); + const moves = await resolveMoves({ + moves: parsedMoves, + asset, + assetDecimals, + activeStrategies, + provider, + resolveDeployment: async (name) => + ( + await deploymentRegistry.getOrNull(name) + )?.address, + log, + }); + + log.info( + `${config.name}: vault ${vaultAddress}, checker ${checkerAddress}, asset ${asset} (${assetDecimals} decimals)` + ); + moves.forEach((move, index) => + log.info( + `Move ${index + 1}: ${move.kind} ${move.strategyIdentifier} (${ + move.strategy + })${ + move.amountUnits + ? ` ${formatUnits(move.amountUnits, assetDecimals)}` + : "" + }` + ) + ); + + const { apiKit, protocolKit } = await createSafeClients({ + provider, + safeAddress, + chainId, + apiKey, + }); + const signerAddress = getAddress(await signer.getAddress()); + await assertRegisteredDelegate({ apiKit, safeAddress, signerAddress }); + const nonceState = await resolveNonce({ + apiKit, + provider, + safeAddress, + requestedNonce: args.nonce, + log, + }); + log.info( + `Safe nonce ${nonceState.nonce} (onchain ${nonceState.onchainNonce}, next available ${nonceState.nextAvailableNonce})` + ); + + let checkerValues; + if (args.skipFork) { + checkerValues = resolveCheckerValues({ + config, + expectedProfit: args.expectedProfit, + profitVariance: args.profitVariance, + expectedVaultChange: args.expectedVaultChange, + vaultChangeVariance: args.vaultChangeVariance, + skipFork: true, + }); + log.warn("Skipping local fork simulation"); + } else { + const blockNumber = await provider.getBlockNumber(); + ({ checkerValues } = await runLocalForkSimulation({ + config, + blockNumber, + safeAddress, + vaultAddress, + checkerAddress, + asset, + oToken, + moves, + expectedProfit: args.expectedProfit, + profitVariance: args.profitVariance, + expectedVaultChange: args.expectedVaultChange, + vaultChangeVariance: args.vaultChangeVariance, + log, + })); + } + + log.info( + `Value Checker: expectedProfit=${formatUnits( + checkerValues.expectedProfit, + 18 + )}, profitVariance=${formatUnits( + checkerValues.profitVariance, + 18 + )}, expectedVaultChange=${formatUnits( + checkerValues.expectedVaultChange, + 18 + )}, vaultChangeVariance=${formatUnits( + checkerValues.vaultChangeVariance, + 18 + )}` + ); + if (args.expectedProfit !== undefined) { + log.info(`Explicit expectedProfit override: ${args.expectedProfit}`); + } + if (args.expectedVaultChange !== undefined) { + log.info( + `Explicit expectedVaultChange override: ${args.expectedVaultChange}` + ); + } + + const calls = buildBatchCalls({ + vaultAddress, + checkerAddress, + asset, + moves, + checkerValues, + }); + calls.forEach((call, index) => + log.info(`Batch call ${index + 1}: ${call.description}`) + ); + const safeTransaction = await createSafeTransaction({ + protocolKit, + calls: toMetaTransactions(calls), + nonce: nonceState.nonce, + }); + const identical = findIdenticalProposal( + nonceState.existing, + safeTransaction + ); + if (identical) { + log.warn( + `Identical proposal already exists: ${safeTransactionUrl( + chainId, + safeAddress, + identical.safeTxHash + )}` + ); + return; + } + + if (args.skipEstimation) { + log.warn("Skipping final Safe estimation"); + } else { + const estimate = await estimateSafeTransaction({ + apiKit, + safeAddress, + transaction: safeTransaction, + }); + log.info(`Safe estimation succeeded: safeTxGas=${estimate.safeTxGas}`); + } + + if (args.dryrun) { + log.info( + "[DRY RUN] Delegate is registered; skipping signature and proposal" + ); + return; + } + await assertNonceStillAvailable({ + provider, + safeAddress, + nonce: nonceState.nonce, + }); + const safeTxHash = await proposeSafeTransaction({ + apiKit, + protocolKit, + safeAddress, + signer, + transaction: safeTransaction, + }); + log.info( + `Proposed Safe transaction ${safeTxHash}: ${safeTransactionUrl( + chainId, + safeAddress, + safeTxHash + )}` + ); + }, +}); diff --git a/contracts/tasks/lib/safeProposal.ts b/contracts/tasks/lib/safeProposal.ts new file mode 100644 index 0000000000..f563052c20 --- /dev/null +++ b/contracts/tasks/lib/safeProposal.ts @@ -0,0 +1,317 @@ +import SafeApiKit from "@safe-global/api-kit"; +import Safe, { adjustVInSignature } from "@safe-global/protocol-kit"; +import { + OperationType, + SigningMethod, + type MetaTransactionData, + type SafeMultisigTransactionResponse, + type SafeTransaction, +} from "@safe-global/types-kit"; +import { Contract, ethers } from "ethers"; +import { arrayify, getAddress } from "ethers/lib/utils"; + +import type { Logger } from "./action"; + +const SAFE_ABI = ["function nonce() external view returns (uint256)"]; + +export interface NonceState { + nonce: number; + onchainNonce: number; + nextAvailableNonce: number; + existing: SafeMultisigTransactionResponse[]; +} + +export interface SafeClients { + apiKit: SafeApiKit; + protocolKit: Safe; +} + +function eip1193Provider(provider: ethers.providers.Provider) { + return { + request: async ({ method, params }: { method: string; params?: unknown }) => + (provider as ethers.providers.JsonRpcProvider).send( + method, + Array.isArray(params) ? params : [] + ), + }; +} + +export async function createSafeClients({ + provider, + safeAddress, + chainId, + apiKey, +}: { + provider: ethers.providers.Provider; + safeAddress: string; + chainId: number; + apiKey: string; +}): Promise { + const [protocolKit] = await Promise.all([ + Safe.init({ + provider: eip1193Provider(provider), + safeAddress, + isL1SafeSingleton: chainId === 1, + }), + ]); + return { + protocolKit, + apiKit: new SafeApiKit({ chainId: BigInt(chainId), apiKey }), + }; +} + +export async function resolveNonce({ + apiKit, + provider, + safeAddress, + requestedNonce, + log, +}: { + apiKit: SafeApiKit; + provider: ethers.providers.Provider; + safeAddress: string; + requestedNonce?: number; + log: Logger; +}): Promise { + const safe = new Contract(safeAddress, SAFE_ABI, provider); + const [onchainNonceBn, nextAvailableRaw] = await Promise.all([ + safe.nonce(), + apiKit.getNextNonce(safeAddress), + ]); + const onchainNonce = onchainNonceBn.toNumber(); + const nextAvailableNonce = Number(nextAvailableRaw); + if (!Number.isSafeInteger(nextAvailableNonce)) { + throw new Error( + `Safe service returned invalid next nonce "${nextAvailableRaw}"` + ); + } + + const nonce = requestedNonce ?? nextAvailableNonce; + if (!Number.isSafeInteger(nonce) || nonce < 0) { + throw new Error("Safe nonce must be a non-negative safe integer"); + } + if (nonce < onchainNonce) { + throw new Error( + `Safe nonce ${nonce} has already been consumed; current onchain nonce is ${onchainNonce}` + ); + } + const response = await apiKit.getMultisigTransactions(safeAddress, { + nonce: String(nonce), + limit: 100, + }); + const existing = response.results.filter( + (transaction) => Number(transaction.nonce) === nonce + ); + return validateNonceSelection({ + requestedNonce, + onchainNonce, + nextAvailableNonce, + existing, + log, + }); +} + +export function validateNonceSelection({ + requestedNonce, + onchainNonce, + nextAvailableNonce, + existing, + log, +}: { + requestedNonce?: number; + onchainNonce: number; + nextAvailableNonce: number; + existing: SafeMultisigTransactionResponse[]; + log: Logger; +}): NonceState { + const nonce = requestedNonce ?? nextAvailableNonce; + if (!Number.isSafeInteger(nonce) || nonce < 0) { + throw new Error("Safe nonce must be a non-negative safe integer"); + } + if (nonce < onchainNonce) { + throw new Error( + `Safe nonce ${nonce} has already been consumed; current onchain nonce is ${onchainNonce}` + ); + } + const executed = existing.find((transaction) => transaction.isExecuted); + if (executed) { + throw new Error( + `Safe nonce ${nonce} was already executed in ${ + executed.transactionHash ?? executed.safeTxHash + }` + ); + } + if (requestedNonce !== undefined && nonce > nextAvailableNonce) { + log.warn( + `Explicit nonce ${nonce} creates a gap after next available nonce ${nextAvailableNonce}` + ); + } + for (const transaction of existing) { + log.warn( + `Unexecuted proposal already uses nonce ${nonce}: ${transaction.safeTxHash}` + ); + } + + return { nonce, onchainNonce, nextAvailableNonce, existing }; +} + +export async function assertNonceStillAvailable({ + provider, + safeAddress, + nonce, +}: { + provider: ethers.providers.Provider; + safeAddress: string; + nonce: number; +}) { + const safe = new Contract(safeAddress, SAFE_ABI, provider); + const current = await safe.nonce(); + if (current.gt(nonce)) { + throw new Error( + `Safe nonce ${nonce} became stale during validation; current onchain nonce is ${current.toString()}` + ); + } +} + +export async function createSafeTransaction({ + protocolKit, + calls, + nonce, +}: { + protocolKit: Safe; + calls: MetaTransactionData[]; + nonce: number; +}): Promise { + return protocolKit.createTransaction({ + transactions: calls, + onlyCalls: true, + options: { nonce }, + }); +} + +export function findIdenticalProposal( + existing: SafeMultisigTransactionResponse[], + transaction: SafeTransaction +) { + return existing.find( + (candidate) => + !candidate.isExecuted && + candidate.to.toLowerCase() === transaction.data.to.toLowerCase() && + candidate.value === transaction.data.value && + (candidate.data ?? "0x").toLowerCase() === + transaction.data.data.toLowerCase() && + candidate.operation === transaction.data.operation + ); +} + +export async function assertRegisteredDelegate({ + apiKit, + safeAddress, + signerAddress, +}: { + apiKit: SafeApiKit; + safeAddress: string; + signerAddress: string; +}) { + const delegates = await apiKit.getSafeDelegates({ + safeAddress, + delegateAddress: signerAddress, + limit: 100, + }); + const registered = delegates.results.some( + (delegate) => + delegate.safe.toLowerCase() === safeAddress.toLowerCase() && + delegate.delegate.toLowerCase() === signerAddress.toLowerCase() && + (!delegate.expiryDate || + new Date(delegate.expiryDate).getTime() > Date.now()) + ); + if (!registered) { + throw new Error( + `Talos signer ${signerAddress} is not registered as a Safe Transaction Service delegate for ${safeAddress}` + ); + } +} + +export async function estimateSafeTransaction({ + apiKit, + safeAddress, + transaction, +}: { + apiKit: SafeApiKit; + safeAddress: string; + transaction: SafeTransaction; +}) { + return apiKit.estimateSafeTransaction(safeAddress, { + to: transaction.data.to, + value: transaction.data.value, + data: transaction.data.data, + operation: transaction.data.operation, + }); +} + +export async function proposeSafeTransaction({ + apiKit, + protocolKit, + safeAddress, + signer, + transaction, +}: { + apiKit: SafeApiKit; + protocolKit: Safe; + safeAddress: string; + signer: ethers.Signer; + transaction: SafeTransaction; +}) { + const signerAddress = getAddress(await signer.getAddress()); + const safeTxHash = await protocolKit.getTransactionHash(transaction); + const senderSignature = await signSafeTransactionHash( + signer, + safeTxHash, + signerAddress + ); + + await apiKit.proposeTransaction({ + safeAddress, + safeTransactionData: transaction.data, + safeTxHash, + senderAddress: signerAddress, + senderSignature, + origin: "Talos: proposeVaultStrategyMoves", + }); + return safeTxHash; +} + +export async function signSafeTransactionHash( + signer: ethers.Signer, + safeTxHash: string, + signerAddress?: string +) { + const address = getAddress(signerAddress ?? (await signer.getAddress())); + const rawSignature = await signer.signMessage(arrayify(safeTxHash)); + return adjustVInSignature( + SigningMethod.ETH_SIGN, + rawSignature, + safeTxHash, + address + ); +} + +export function toMetaTransactions( + calls: { to: string; value: string; data: string }[] +): MetaTransactionData[] { + return calls.map((call) => ({ + to: call.to, + value: call.value, + data: call.data, + operation: OperationType.Call, + })); +} + +export function safeTransactionUrl( + chainId: number, + safeAddress: string, + safeTxHash: string +) { + const prefix = chainId === 1 ? "eth" : chainId === 8453 ? "base" : chainId; + return `https://app.safe.global/transactions/tx?safe=${prefix}:${safeAddress}&id=multisig_${safeAddress}_${safeTxHash}`; +} diff --git a/contracts/tasks/lib/vaultStrategyMoves.ts b/contracts/tasks/lib/vaultStrategyMoves.ts new file mode 100644 index 0000000000..6340ce8b03 --- /dev/null +++ b/contracts/tasks/lib/vaultStrategyMoves.ts @@ -0,0 +1,655 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { createServer } from "node:net"; +import { resolve } from "node:path"; + +import { BigNumber, Contract, ethers } from "ethers"; +import { + formatUnits, + getAddress, + isAddress, + parseEther, + parseUnits, +} from "ethers/lib/utils"; + +import type { Logger } from "./action"; + +export type VaultName = "OUSD" | "OETH" | "SuperOETH"; +export type MoveKind = "deposit" | "withdraw" | "withdrawAll"; + +export interface VaultConfig { + name: VaultName; + chainId: number; + vaultDeployment: string; + checkerDeployment: string; + profitVariance: string; + vaultChangeVariance: string; + providerEnv: "PROVIDER_URL" | "BASE_PROVIDER_URL"; +} + +export interface ParsedMove { + kind: MoveKind; + strategyIdentifier: string; + amount?: string; +} + +export interface ResolvedMove extends ParsedMove { + strategy: string; + amountUnits?: BigNumber; +} + +export interface CheckerValues { + expectedProfit: BigNumber; + profitVariance: BigNumber; + expectedVaultChange: BigNumber; + vaultChangeVariance: BigNumber; +} + +export interface DerivedValues { + profit: BigNumber; + vaultChange: BigNumber; + supplyChange: BigNumber; +} + +export interface BatchCall { + to: string; + value: string; + data: string; + description: string; +} + +export const VAULT_ABI = [ + "function asset() external view returns (address)", + "function oToken() external view returns (address)", + "function strategistAddr() external view returns (address)", + "function rebase() external", + "function totalValue() external view returns (uint256)", + "function getAllStrategies() external view returns (address[])", + "function depositToStrategy(address,address[],uint256[]) external", + "function withdrawFromStrategy(address,address[],uint256[]) external", + "function withdrawAllFromStrategy(address) external", +]; + +export const CHECKER_ABI = [ + "function takeSnapshot() external", + "function snapshots(address) external view returns (uint256 vaultValue,uint256 totalSupply,uint256 time)", + "function checkDelta(int256 expectedProfit,int256 profitVariance,int256 expectedVaultChange,int256 vaultChangeVariance) external", +]; + +export const TOKEN_ABI = [ + "function decimals() external view returns (uint8)", + "function totalSupply() external view returns (uint256)", +]; + +const STRATEGY_ABI = [ + "function supportsAsset(address) external view returns (bool)", +]; + +const VAULT_CONFIGS: Record = { + OUSD: { + name: "OUSD", + chainId: 1, + vaultDeployment: "VaultProxy", + checkerDeployment: "VaultValueChecker", + profitVariance: "100", + vaultChangeVariance: "100", + providerEnv: "PROVIDER_URL", + }, + OETH: { + name: "OETH", + chainId: 1, + vaultDeployment: "OETHVaultProxy", + checkerDeployment: "OETHVaultValueChecker", + profitVariance: "1", + vaultChangeVariance: "1", + providerEnv: "PROVIDER_URL", + }, + SuperOETH: { + name: "SuperOETH", + chainId: 8453, + vaultDeployment: "OETHBaseVaultProxy", + checkerDeployment: "OETHVaultValueChecker", + profitVariance: "1", + vaultChangeVariance: "10", + providerEnv: "BASE_PROVIDER_URL", + }, +}; + +const DECIMAL_VALUE = /^(?:0|[1-9]\d*)(?:\.\d+)?$/; +const SIGNED_DECIMAL_VALUE = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/; +const FORK_TRANSACTION_OVERRIDES = { gasLimit: 100_000_000 }; + +export function getVaultConfig(vault: string, chainId: number): VaultConfig { + const normalized = vault.trim().toLowerCase(); + const config = Object.values(VAULT_CONFIGS).find( + (candidate) => candidate.name.toLowerCase() === normalized + ); + if (!config) { + throw new Error( + `Unsupported vault "${vault}". Use OUSD, OETH, or SuperOETH` + ); + } + if (config.chainId !== chainId) { + throw new Error( + `${config.name} is not supported on chain ${chainId}; expected chain ${config.chainId}` + ); + } + return config; +} + +export function parseMoves(input: string): ParsedMove[] { + if (!input?.trim()) throw new Error("At least one strategy move is required"); + + return input.split(";").map((raw, index) => { + const entry = raw.trim(); + if (!entry) throw new Error(`Move ${index + 1} is empty`); + const parts = entry.split(":").map((part) => part.trim()); + const kind = parts[0] as MoveKind; + + if (!(["deposit", "withdraw", "withdrawAll"] as string[]).includes(kind)) { + throw new Error( + `Move ${index + 1} has unsupported operation "${parts[0]}"` + ); + } + if (!parts[1]) throw new Error(`Move ${index + 1} is missing a strategy`); + + if (kind === "withdrawAll") { + if (parts.length !== 2) { + throw new Error( + `Move ${index + 1} withdrawAll must not include an amount` + ); + } + return { kind, strategyIdentifier: parts[1] }; + } + + if (parts.length !== 3 || !parts[2]) { + throw new Error(`Move ${index + 1} ${kind} requires a positive amount`); + } + if (!DECIMAL_VALUE.test(parts[2]) || !/[1-9]/.test(parts[2])) { + throw new Error( + `Move ${ + index + 1 + } amount must be a positive decimal without exponent notation` + ); + } + return { kind, strategyIdentifier: parts[1], amount: parts[2] }; + }); +} + +export function parseSignedValue(value: string, label: string): BigNumber { + if (!SIGNED_DECIMAL_VALUE.test(value)) { + throw new Error( + `${label} must be a signed decimal without exponent notation` + ); + } + return parseUnits(value, 18); +} + +export function parseUnsignedValue(value: string, label: string): BigNumber { + if (!DECIMAL_VALUE.test(value)) { + throw new Error( + `${label} must be a non-negative decimal without exponent notation` + ); + } + return parseUnits(value, 18); +} + +export function calculateDerivedValues( + snapshotVaultValue: BigNumber, + snapshotTotalSupply: BigNumber, + postVaultValue: BigNumber, + postTotalSupply: BigNumber +): DerivedValues { + const vaultChange = postVaultValue.sub(snapshotVaultValue); + const supplyChange = postTotalSupply.sub(snapshotTotalSupply); + return { + vaultChange, + supplyChange, + profit: vaultChange.sub(supplyChange), + }; +} + +export function resolveCheckerValues({ + config, + derived, + expectedProfit, + profitVariance, + expectedVaultChange, + vaultChangeVariance, + skipFork, +}: { + config: VaultConfig; + derived?: DerivedValues; + expectedProfit?: string; + profitVariance?: string; + expectedVaultChange?: string; + vaultChangeVariance?: string; + skipFork: boolean; +}): CheckerValues { + if (skipFork && expectedProfit === undefined) { + throw new Error("--expected-profit is required when --skip-fork is used"); + } + if (skipFork && expectedVaultChange === undefined) { + throw new Error( + "--expected-vault-change is required when --skip-fork is used" + ); + } + if (!derived && !skipFork) { + throw new Error("Fork-derived Value Checker values are missing"); + } + + return { + expectedProfit: + expectedProfit !== undefined + ? parseSignedValue(expectedProfit, "expectedProfit") + : derived!.profit, + profitVariance: parseUnsignedValue( + profitVariance ?? config.profitVariance, + "profitVariance" + ), + expectedVaultChange: + expectedVaultChange !== undefined + ? parseSignedValue(expectedVaultChange, "expectedVaultChange") + : derived!.vaultChange, + vaultChangeVariance: parseUnsignedValue( + vaultChangeVariance ?? config.vaultChangeVariance, + "vaultChangeVariance" + ), + }; +} + +export async function resolveMoves({ + moves, + asset, + assetDecimals, + activeStrategies, + provider, + resolveDeployment, + log, +}: { + moves: ParsedMove[]; + asset: string; + assetDecimals: number; + activeStrategies: string[]; + provider: ethers.providers.Provider; + resolveDeployment: (name: string) => Promise; + log: Logger; +}): Promise { + const active = new Set( + activeStrategies.map((address) => address.toLowerCase()) + ); + const supportChecks = new Map(); + + const resolved: ResolvedMove[] = []; + for (const move of moves) { + const strategy = isAddress(move.strategyIdentifier) + ? getAddress(move.strategyIdentifier) + : await resolveDeployment(move.strategyIdentifier); + if (!strategy) { + throw new Error( + `Unknown strategy deployment "${move.strategyIdentifier}"` + ); + } + const checksummed = getAddress(strategy); + if (!active.has(checksummed.toLowerCase())) { + throw new Error( + `Strategy ${move.strategyIdentifier} (${checksummed}) is not active in the selected vault` + ); + } + + if (!supportChecks.has(checksummed.toLowerCase())) { + try { + const contract = new Contract(checksummed, STRATEGY_ABI, provider); + supportChecks.set( + checksummed.toLowerCase(), + await contract.supportsAsset(asset) + ); + } catch (error: any) { + supportChecks.set(checksummed.toLowerCase(), undefined); + log.warn( + `Could not query supportsAsset on ${ + move.strategyIdentifier + } (${checksummed}); relying on vault approval: ${ + error?.reason ?? error?.message ?? error + }` + ); + } + } + if (supportChecks.get(checksummed.toLowerCase()) === false) { + throw new Error( + `Strategy ${move.strategyIdentifier} (${checksummed}) does not support vault asset ${asset}` + ); + } + + resolved.push({ + ...move, + strategy: checksummed, + amountUnits: + move.amount === undefined + ? undefined + : (() => { + try { + return parseUnits(move.amount, assetDecimals); + } catch { + throw new Error( + `Amount ${move.amount} for ${move.strategyIdentifier} exceeds the asset's ${assetDecimals} decimal precision` + ); + } + })(), + }); + } + return resolved; +} + +export function buildBatchCalls({ + vaultAddress, + checkerAddress, + asset, + moves, + checkerValues, +}: { + vaultAddress: string; + checkerAddress: string; + asset: string; + moves: ResolvedMove[]; + checkerValues: CheckerValues; +}): BatchCall[] { + const vault = new ethers.utils.Interface(VAULT_ABI); + const checker = new ethers.utils.Interface(CHECKER_ABI); + const calls: BatchCall[] = [ + { + to: vaultAddress, + value: "0", + data: vault.encodeFunctionData("rebase"), + description: "vault.rebase()", + }, + { + to: checkerAddress, + value: "0", + data: checker.encodeFunctionData("takeSnapshot"), + description: "valueChecker.takeSnapshot()", + }, + ]; + + for (const move of moves) { + if (move.kind === "withdrawAll") { + calls.push({ + to: vaultAddress, + value: "0", + data: vault.encodeFunctionData("withdrawAllFromStrategy", [ + move.strategy, + ]), + description: `withdrawAll:${move.strategyIdentifier}`, + }); + } else { + const method = + move.kind === "deposit" ? "depositToStrategy" : "withdrawFromStrategy"; + calls.push({ + to: vaultAddress, + value: "0", + data: vault.encodeFunctionData(method, [ + move.strategy, + [asset], + [move.amountUnits!], + ]), + description: `${move.kind}:${move.strategyIdentifier}:${move.amount}`, + }); + } + } + + calls.push({ + to: checkerAddress, + value: "0", + data: checker.encodeFunctionData("checkDelta", [ + checkerValues.expectedProfit, + checkerValues.profitVariance, + checkerValues.expectedVaultChange, + checkerValues.vaultChangeVariance, + ]), + description: "valueChecker.checkDelta(...)", + }); + return calls; +} + +async function getFreePort(): Promise { + return new Promise((resolvePort, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Could not allocate a local fork port")); + return; + } + const port = address.port; + server.close((error) => (error ? reject(error) : resolvePort(port))); + }); + }); +} + +async function waitForFork( + provider: ethers.providers.JsonRpcProvider, + child: ChildProcess, + timeoutMs: number +) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`Local Hardhat fork exited with code ${child.exitCode}`); + } + try { + await provider.send("web3_clientVersion", []); + return; + } catch { + await new Promise((resolveWait) => setTimeout(resolveWait, 250)); + } + } + throw new Error( + `Timed out waiting ${timeoutMs}ms for the local Hardhat fork` + ); +} + +async function stopFork(child: ChildProcess) { + if (child.exitCode !== null) return; + child.kill("SIGTERM"); + await Promise.race([ + new Promise((resolveExit) => child.once("exit", () => resolveExit())), + new Promise((resolveTimeout) => + setTimeout(() => { + if (child.exitCode === null) child.kill("SIGKILL"); + resolveTimeout(); + }, 5_000) + ), + ]); +} + +export async function runLocalForkSimulation({ + config, + blockNumber, + safeAddress, + vaultAddress, + checkerAddress, + asset, + oToken, + moves, + expectedProfit, + profitVariance, + expectedVaultChange, + vaultChangeVariance, + log, +}: { + config: VaultConfig; + blockNumber: number; + safeAddress: string; + vaultAddress: string; + checkerAddress: string; + asset: string; + oToken: string; + moves: ResolvedMove[]; + expectedProfit?: string; + profitVariance?: string; + expectedVaultChange?: string; + vaultChangeVariance?: string; + log: Logger; +}): Promise<{ derived: DerivedValues; checkerValues: CheckerValues }> { + const upstreamUrl = + process.env[config.providerEnv] || + (config.chainId === 1 ? process.env.MAINNET_PROVIDER_URL : undefined); + if (!upstreamUrl) { + throw new Error( + `${config.providerEnv} is required for local fork simulation` + ); + } + + const port = await getFreePort(); + const contractsDir = resolve(__dirname, "../.."); + const hardhatBin = resolve(contractsDir, "node_modules/.bin/hardhat"); + const env: NodeJS.ProcessEnv = { + ...process.env, + FORK: "true", + IS_TEST: "true", + FORK_NETWORK_NAME: config.chainId === 8453 ? "base" : "mainnet", + }; + delete env.HARDHAT_NETWORK; + delete env.NETWORK_NAME; + delete env.LOCAL_PROVIDER_URL; + if (config.chainId === 8453) env.BASE_BLOCK_NUMBER = String(blockNumber); + else env.BLOCK_NUMBER = String(blockNumber); + + const child = spawn( + hardhatBin, + ["node", "--hostname", "127.0.0.1", "--port", String(port), "--no-deploy"], + { cwd: contractsDir, env, stdio: ["ignore", "pipe", "pipe"] } + ); + let childOutput = ""; + const captureOutput = (chunk: Buffer) => { + childOutput = `${childOutput}${chunk.toString()}`.slice(-64 * 1024); + }; + child.stdout?.on("data", captureOutput); + child.stderr?.on("data", captureOutput); + + const provider = new ethers.providers.JsonRpcProvider( + `http://127.0.0.1:${port}`, + config.chainId + ); + const forwardSignal = (signal: NodeJS.Signals) => { + child.kill(signal); + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + process.kill(process.pid, signal); + }; + const onSigint = () => forwardSignal("SIGINT"); + const onSigterm = () => forwardSignal("SIGTERM"); + process.once("SIGINT", onSigint); + process.once("SIGTERM", onSigterm); + + try { + log.info(`Starting ${config.name} fork simulation at block ${blockNumber}`); + await waitForFork(provider, child, 45_000); + await provider.send("hardhat_impersonateAccount", [safeAddress]); + await provider.send("hardhat_setBalance", [ + safeAddress, + parseEther("10").toHexString(), + ]); + + const safeSigner = provider.getSigner(safeAddress); + const vault = new Contract(vaultAddress, VAULT_ABI, safeSigner); + const checker = new Contract(checkerAddress, CHECKER_ABI, safeSigner); + const token = new Contract(oToken, TOKEN_ABI, provider); + + await (await vault.rebase(FORK_TRANSACTION_OVERRIDES)).wait(); + await (await checker.takeSnapshot(FORK_TRANSACTION_OVERRIDES)).wait(); + + for (const move of moves) { + if (move.kind === "deposit") { + await ( + await vault.depositToStrategy( + move.strategy, + [asset], + [move.amountUnits], + FORK_TRANSACTION_OVERRIDES + ) + ).wait(); + } else if (move.kind === "withdraw") { + await ( + await vault.withdrawFromStrategy( + move.strategy, + [asset], + [move.amountUnits], + FORK_TRANSACTION_OVERRIDES + ) + ).wait(); + } else { + await ( + await vault.withdrawAllFromStrategy( + move.strategy, + FORK_TRANSACTION_OVERRIDES + ) + ).wait(); + } + } + + const snapshot = await checker.snapshots(safeAddress); + const postVaultValue = await vault.totalValue(); + const postTotalSupply = await token.totalSupply(); + const derived = calculateDerivedValues( + snapshot.vaultValue, + snapshot.totalSupply, + postVaultValue, + postTotalSupply + ); + const checkerValues = resolveCheckerValues({ + config, + derived, + expectedProfit, + profitVariance, + expectedVaultChange, + vaultChangeVariance, + skipFork: false, + }); + + log.info( + `Fork vault value: ${formatUnits( + snapshot.vaultValue, + 18 + )} -> ${formatUnits(postVaultValue, 18)}; change ${formatUnits( + derived.vaultChange, + 18 + )}` + ); + log.info( + `Fork token supply: ${formatUnits( + snapshot.totalSupply, + 18 + )} -> ${formatUnits(postTotalSupply, 18)}; change ${formatUnits( + derived.supplyChange, + 18 + )}` + ); + log.info(`Fork-derived profit: ${formatUnits(derived.profit, 18)}`); + + await ( + await checker.checkDelta( + checkerValues.expectedProfit, + checkerValues.profitVariance, + checkerValues.expectedVaultChange, + checkerValues.vaultChangeVariance, + FORK_TRANSACTION_OVERRIDES + ) + ).wait(); + log.info("Fork Value Checker validation succeeded"); + return { derived, checkerValues }; + } catch (error: any) { + const sanitized = childOutput + .replaceAll(upstreamUrl, "[redacted provider URL]") + .split("\n") + .slice(-12) + .join("\n"); + if (sanitized.trim()) log.warn(`Local fork output:\n${sanitized}`); + throw error; + } finally { + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + await stopFork(child); + } +} diff --git a/contracts/test/tasks/vault-strategy-moves.js b/contracts/test/tasks/vault-strategy-moves.js new file mode 100644 index 0000000000..bd23037e23 --- /dev/null +++ b/contracts/test/tasks/vault-strategy-moves.js @@ -0,0 +1,305 @@ +const { expect } = require("chai"); +const { BigNumber, ethers } = require("ethers"); + +const { + CHECKER_ABI, + VAULT_ABI, + buildBatchCalls, + calculateDerivedValues, + getVaultConfig, + parseMoves, + resolveCheckerValues, +} = require("../../tasks/lib/vaultStrategyMoves"); +const { + findIdenticalProposal, + signSafeTransactionHash, + validateNonceSelection, +} = require("../../tasks/lib/safeProposal"); + +const strategyA = "0x0000000000000000000000000000000000000011"; +const strategyB = "0x0000000000000000000000000000000000000022"; +const vaultAddress = "0x0000000000000000000000000000000000000033"; +const checkerAddress = "0x0000000000000000000000000000000000000044"; +const asset = "0x0000000000000000000000000000000000000055"; + +describe("Talos vault strategy moves", () => { + it("parses mixed movements without changing their order", () => { + expect( + parseMoves( + "withdraw:StrategyA:5.25;deposit:StrategyB:2;withdrawAll:StrategyA" + ) + ).to.deep.equal([ + { + kind: "withdraw", + strategyIdentifier: "StrategyA", + amount: "5.25", + }, + { + kind: "deposit", + strategyIdentifier: "StrategyB", + amount: "2", + }, + { kind: "withdrawAll", strategyIdentifier: "StrategyA" }, + ]); + }); + + for (const moves of [ + "deposit:StrategyA:0", + "withdraw:StrategyA:-1", + "deposit:StrategyA:1e6", + "withdrawAll:StrategyA:1", + "unknown:StrategyA:1", + "deposit::1", + ]) { + it(`rejects invalid movement syntax: ${moves}`, () => { + expect(() => parseMoves(moves)).to.throw(); + }); + } + + it("enforces vault and chain combinations", () => { + expect(getVaultConfig("ousd", 1).vaultDeployment).to.equal("VaultProxy"); + expect(getVaultConfig("SuperOETH", 8453).vaultDeployment).to.equal( + "OETHBaseVaultProxy" + ); + expect(() => getVaultConfig("SuperOETH", 1)).to.throw( + "expected chain 8453" + ); + }); + + it("derives signed vault change, supply change, and profit", () => { + const values = calculateDerivedValues( + BigNumber.from(1000), + BigNumber.from(900), + BigNumber.from(800), + BigNumber.from(950) + ); + expect(values.vaultChange.toString()).to.equal("-200"); + expect(values.supplyChange.toString()).to.equal("50"); + expect(values.profit.toString()).to.equal("-250"); + }); + + it("uses derived expected values and OUSD variance defaults", () => { + const values = resolveCheckerValues({ + config: getVaultConfig("OUSD", 1), + derived: { + profit: ethers.utils.parseUnits("-7", 18), + vaultChange: ethers.utils.parseUnits("20", 18), + supplyChange: ethers.utils.parseUnits("27", 18), + }, + skipFork: false, + }); + expect(ethers.utils.formatUnits(values.expectedProfit, 18)).to.equal( + "-7.0" + ); + expect(ethers.utils.formatUnits(values.expectedVaultChange, 18)).to.equal( + "20.0" + ); + expect(ethers.utils.formatUnits(values.profitVariance, 18)).to.equal( + "100.0" + ); + expect(ethers.utils.formatUnits(values.vaultChangeVariance, 18)).to.equal( + "100.0" + ); + }); + + it("uses SuperOETH variance defaults and independent expected overrides", () => { + const values = resolveCheckerValues({ + config: getVaultConfig("SuperOETH", 8453), + derived: { + profit: BigNumber.from(1), + vaultChange: BigNumber.from(2), + supplyChange: BigNumber.from(1), + }, + expectedProfit: "3.5", + skipFork: false, + }); + expect(ethers.utils.formatUnits(values.expectedProfit, 18)).to.equal("3.5"); + expect(values.expectedVaultChange.toString()).to.equal("2"); + expect(ethers.utils.formatUnits(values.profitVariance, 18)).to.equal("1.0"); + expect(ethers.utils.formatUnits(values.vaultChangeVariance, 18)).to.equal( + "10.0" + ); + }); + + it("requires both expected values when the fork is skipped", () => { + const config = getVaultConfig("OETH", 1); + expect(() => + resolveCheckerValues({ config, expectedProfit: "0", skipFork: true }) + ).to.throw("--expected-vault-change is required"); + expect(() => + resolveCheckerValues({ + config, + expectedVaultChange: "0", + skipFork: true, + }) + ).to.throw("--expected-profit is required"); + + const values = resolveCheckerValues({ + config, + expectedProfit: "0", + expectedVaultChange: "0", + skipFork: true, + }); + expect(values.expectedProfit.isZero()).to.equal(true); + expect(values.expectedVaultChange.isZero()).to.equal(true); + }); + + it("builds rebase, snapshot, ordered moves, and checkDelta", () => { + const checkerValues = resolveCheckerValues({ + config: getVaultConfig("OETH", 1), + expectedProfit: "0", + expectedVaultChange: "0", + skipFork: true, + }); + const calls = buildBatchCalls({ + vaultAddress, + checkerAddress, + asset, + moves: [ + { + kind: "withdraw", + strategyIdentifier: "StrategyA", + strategy: strategyA, + amount: "3", + amountUnits: ethers.utils.parseEther("3"), + }, + { + kind: "deposit", + strategyIdentifier: "StrategyB", + strategy: strategyB, + amount: "2", + amountUnits: ethers.utils.parseEther("2"), + }, + { + kind: "withdrawAll", + strategyIdentifier: "StrategyA", + strategy: strategyA, + }, + ], + checkerValues, + }); + expect(calls.map((call) => call.description)).to.deep.equal([ + "vault.rebase()", + "valueChecker.takeSnapshot()", + "withdraw:StrategyA:3", + "deposit:StrategyB:2", + "withdrawAll:StrategyA", + "valueChecker.checkDelta(...)", + ]); + + const vaultInterface = new ethers.utils.Interface(VAULT_ABI); + const checkerInterface = new ethers.utils.Interface(CHECKER_ABI); + expect( + vaultInterface.parseTransaction({ data: calls[0].data }).name + ).to.equal("rebase"); + expect( + checkerInterface.parseTransaction({ data: calls[1].data }).name + ).to.equal("takeSnapshot"); + expect( + vaultInterface.parseTransaction({ data: calls[2].data }).name + ).to.equal("withdrawFromStrategy"); + expect( + vaultInterface.parseTransaction({ data: calls[3].data }).name + ).to.equal("depositToStrategy"); + expect( + vaultInterface.parseTransaction({ data: calls[4].data }).name + ).to.equal("withdrawAllFromStrategy"); + expect( + checkerInterface.parseTransaction({ data: calls[5].data }).name + ).to.equal("checkDelta"); + }); + + it("finds an identical unexecuted same-nonce proposal", () => { + const transaction = { + data: { + to: vaultAddress, + value: "0", + data: "0x1234", + operation: 1, + }, + }; + const existing = [ + { + to: vaultAddress, + value: "0", + data: "0x1234", + operation: 1, + isExecuted: false, + safeTxHash: "0xabc", + }, + ]; + expect(findIdenticalProposal(existing, transaction).safeTxHash).to.equal( + "0xabc" + ); + existing[0].isExecuted = true; + expect(findIdenticalProposal(existing, transaction)).to.equal(undefined); + }); + + it("defaults to the next Safe nonce and permits an unexecuted replacement", () => { + const warnings = []; + const log = { warn: (message) => warnings.push(message) }; + expect( + validateNonceSelection({ + onchainNonce: 10, + nextAvailableNonce: 12, + existing: [], + log, + }).nonce + ).to.equal(12); + + const replacement = validateNonceSelection({ + requestedNonce: 10, + onchainNonce: 10, + nextAvailableNonce: 12, + existing: [{ nonce: "10", isExecuted: false, safeTxHash: "0xpending" }], + log, + }); + expect(replacement.nonce).to.equal(10); + expect(replacement.existing).to.have.length(1); + expect(warnings[0]).to.contain("0xpending"); + }); + + it("rejects consumed or explicitly executed Safe nonces", () => { + const log = { warn: () => undefined }; + expect(() => + validateNonceSelection({ + requestedNonce: 9, + onchainNonce: 10, + nextAvailableNonce: 12, + existing: [], + log, + }) + ).to.throw("already been consumed"); + expect(() => + validateNonceSelection({ + requestedNonce: 10, + onchainNonce: 10, + nextAvailableNonce: 12, + existing: [ + { + nonce: "10", + isExecuted: true, + safeTxHash: "0xexecuted", + transactionHash: "0xonchain", + }, + ], + log, + }) + ).to.throw("already executed in 0xonchain"); + }); + + it("normalizes an ethers signMessage signature for Safe eth_sign", async () => { + const wallet = ethers.Wallet.createRandom(); + const hash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes("safe hash")); + const signature = await signSafeTransactionHash(wallet, hash); + const safeV = Number(`0x${signature.slice(-2)}`); + expect([31, 32]).to.include(safeV); + + const ethersSignature = `${signature.slice(0, -2)}${(safeV - 4) + .toString(16) + .padStart(2, "0")}`; + expect( + ethers.utils.verifyMessage(ethers.utils.arrayify(hash), ethersSignature) + ).to.equal(wallet.address); + }); +}); From ed5fd59b90281b18c3d237aa56fe48ea657eecac Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:26:21 +0400 Subject: [PATCH 2/2] Get rid of hardhat --- .../actions/proposeVaultStrategyMoves.ts | 40 +- contracts/tasks/lib/vaultStrategyMoves.ts | 518 ++++++++++-------- contracts/test/tasks/vault-strategy-moves.js | 152 +++++ 3 files changed, 462 insertions(+), 248 deletions(-) diff --git a/contracts/tasks/actions/proposeVaultStrategyMoves.ts b/contracts/tasks/actions/proposeVaultStrategyMoves.ts index de51b72a5c..a9100dd43c 100644 --- a/contracts/tasks/actions/proposeVaultStrategyMoves.ts +++ b/contracts/tasks/actions/proposeVaultStrategyMoves.ts @@ -1,11 +1,9 @@ -/// - import { Contract } from "ethers"; import { formatUnits, getAddress } from "ethers/lib/utils"; -import { types } from "hardhat/config"; import addresses from "../../utils/addresses"; -import { action } from "../lib/action"; +import { action, types } from "../lib/action"; +import { getContract } from "../lib/contracts"; import { assertNonceStillAvailable, assertRegisteredDelegate, @@ -26,9 +24,19 @@ import { parseMoves, resolveCheckerValues, resolveMoves, - runLocalForkSimulation, + runSimulation, } from "../lib/vaultStrategyMoves"; +/// `getContract` throws on a missing deployment, but both callers here need to distinguish +/// "not deployed on this chain" from a hard failure, so the miss is folded back to undefined. +const deploymentAddress = async (name: string): Promise => { + try { + return (await getContract(name)).address; + } catch { + return undefined; + } +}; + action({ name: "proposeVaultStrategyMoves", description: @@ -74,7 +82,7 @@ action({ ); t.addFlag( "skipFork", - "Skip the local fork; expectedProfit and expectedVaultChange become required" + "Skip simulation; expectedProfit and expectedVaultChange become required" ); t.addFlag("skipEstimation", "Skip final Safe transaction estimation"); t.addFlag("dryrun", "Validate and simulate without proposing"); @@ -86,10 +94,9 @@ action({ const apiKey = process.env.SAFE_API_KEY; if (!apiKey) throw new Error("SAFE_API_KEY is required"); - const deploymentRegistry = (hre as any).deployments; const [vaultDeployment, checkerDeployment] = await Promise.all([ - deploymentRegistry.getOrNull(config.vaultDeployment), - deploymentRegistry.getOrNull(config.checkerDeployment), + deploymentAddress(config.vaultDeployment), + deploymentAddress(config.checkerDeployment), ]); if (!vaultDeployment) { throw new Error( @@ -101,8 +108,8 @@ action({ `Missing ${config.checkerDeployment} deployment on the selected network` ); } - const vaultAddress = getAddress(vaultDeployment.address); - const checkerAddress = getAddress(checkerDeployment.address); + const vaultAddress = getAddress(vaultDeployment); + const checkerAddress = getAddress(checkerDeployment); const vault = new Contract(vaultAddress, VAULT_ABI, provider); const [assetRaw, oTokenRaw, strategistRaw, activeStrategies] = await Promise.all([ @@ -129,10 +136,7 @@ action({ assetDecimals, activeStrategies, provider, - resolveDeployment: async (name) => - ( - await deploymentRegistry.getOrNull(name) - )?.address, + resolveDeployment: deploymentAddress, log, }); @@ -180,11 +184,13 @@ action({ vaultChangeVariance: args.vaultChangeVariance, skipFork: true, }); - log.warn("Skipping local fork simulation"); + log.warn("Skipping simulation"); } else { + // Pinned once so both simulation passes see identical state. const blockNumber = await provider.getBlockNumber(); - ({ checkerValues } = await runLocalForkSimulation({ + ({ checkerValues } = await runSimulation({ config, + provider, blockNumber, safeAddress, vaultAddress, diff --git a/contracts/tasks/lib/vaultStrategyMoves.ts b/contracts/tasks/lib/vaultStrategyMoves.ts index 6340ce8b03..63b333075d 100644 --- a/contracts/tasks/lib/vaultStrategyMoves.ts +++ b/contracts/tasks/lib/vaultStrategyMoves.ts @@ -1,11 +1,8 @@ -import { spawn, type ChildProcess } from "node:child_process"; -import { createServer } from "node:net"; -import { resolve } from "node:path"; - import { BigNumber, Contract, ethers } from "ethers"; import { formatUnits, getAddress, + hexValue, isAddress, parseEther, parseUnits, @@ -23,7 +20,6 @@ export interface VaultConfig { checkerDeployment: string; profitVariance: string; vaultChangeVariance: string; - providerEnv: "PROVIDER_URL" | "BASE_PROVIDER_URL"; } export interface ParsedMove { @@ -92,7 +88,6 @@ const VAULT_CONFIGS: Record = { checkerDeployment: "VaultValueChecker", profitVariance: "100", vaultChangeVariance: "100", - providerEnv: "PROVIDER_URL", }, OETH: { name: "OETH", @@ -101,7 +96,6 @@ const VAULT_CONFIGS: Record = { checkerDeployment: "OETHVaultValueChecker", profitVariance: "1", vaultChangeVariance: "1", - providerEnv: "PROVIDER_URL", }, SuperOETH: { name: "SuperOETH", @@ -110,13 +104,16 @@ const VAULT_CONFIGS: Record = { checkerDeployment: "OETHVaultValueChecker", profitVariance: "1", vaultChangeVariance: "10", - providerEnv: "BASE_PROVIDER_URL", }, }; const DECIMAL_VALUE = /^(?:0|[1-9]\d*)(?:\.\d+)?$/; const SIGNED_DECIMAL_VALUE = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/; -const FORK_TRANSACTION_OVERRIDES = { gasLimit: 100_000_000 }; + +const SIMULATE_METHOD = "eth_simulateV1"; +/// Covers gas for the spoofed `from: safe` calls only; never moves real value. +const SIMULATED_SAFE_BALANCE = parseEther("10").toHexString(); +const ERROR_STRING_SELECTOR = "0x08c379a0"; export function getVaultConfig(vault: string, chainId: number): VaultConfig { const normalized = vault.trim().toLowerCase(); @@ -340,6 +337,40 @@ export async function resolveMoves({ return resolved; } +/// Shared by the proposed batch and the simulation's derive pass so a move is only ever encoded +/// one way. +function buildMoveCalls( + vaultAddress: string, + asset: string, + moves: ResolvedMove[] +): BatchCall[] { + const vault = new ethers.utils.Interface(VAULT_ABI); + return moves.map((move) => { + if (move.kind === "withdrawAll") { + return { + to: vaultAddress, + value: "0", + data: vault.encodeFunctionData("withdrawAllFromStrategy", [ + move.strategy, + ]), + description: `withdrawAll:${move.strategyIdentifier}`, + }; + } + const method = + move.kind === "deposit" ? "depositToStrategy" : "withdrawFromStrategy"; + return { + to: vaultAddress, + value: "0", + data: vault.encodeFunctionData(method, [ + move.strategy, + [asset], + [move.amountUnits!], + ]), + description: `${move.kind}:${move.strategyIdentifier}:${move.amount}`, + }; + }); +} + export function buildBatchCalls({ vaultAddress, checkerAddress, @@ -368,34 +399,9 @@ export function buildBatchCalls({ data: checker.encodeFunctionData("takeSnapshot"), description: "valueChecker.takeSnapshot()", }, + ...buildMoveCalls(vaultAddress, asset, moves), ]; - for (const move of moves) { - if (move.kind === "withdrawAll") { - calls.push({ - to: vaultAddress, - value: "0", - data: vault.encodeFunctionData("withdrawAllFromStrategy", [ - move.strategy, - ]), - description: `withdrawAll:${move.strategyIdentifier}`, - }); - } else { - const method = - move.kind === "deposit" ? "depositToStrategy" : "withdrawFromStrategy"; - calls.push({ - to: vaultAddress, - value: "0", - data: vault.encodeFunctionData(method, [ - move.strategy, - [asset], - [move.amountUnits!], - ]), - description: `${move.kind}:${move.strategyIdentifier}:${move.amount}`, - }); - } - } - calls.push({ to: checkerAddress, value: "0", @@ -410,61 +416,143 @@ export function buildBatchCalls({ return calls; } -async function getFreePort(): Promise { - return new Promise((resolvePort, reject) => { - const server = createServer(); - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(); - reject(new Error("Could not allocate a local fork port")); - return; - } - const port = address.port; - server.close((error) => (error ? reject(error) : resolvePort(port))); - }); - }); +interface SimulatedCall { + status: string; + returnData: string; + gasUsed: string; + error?: { code?: number; message?: string; data?: string }; } -async function waitForFork( - provider: ethers.providers.JsonRpcProvider, - child: ChildProcess, - timeoutMs: number -) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (child.exitCode !== null) { - throw new Error(`Local Hardhat fork exited with code ${child.exitCode}`); - } +/// A provider that cannot simulate is a hard stop, not a reason to propose an unchecked batch, so +/// this spells out the one supported way forward instead of letting a raw -32601 surface. +function simulationRpcError(error: any): Error { + const code = error?.error?.code ?? error?.code; + const message: string = + error?.error?.message ?? error?.message ?? String(error); + if (code === -32601 || /method not (found|supported)/i.test(message)) { + return new Error( + `The configured RPC does not support ${SIMULATE_METHOD} (${message}). ` + + `Point the network at a provider that implements it, or re-run with --skip-fork plus ` + + `explicit --expected-profit and --expected-vault-change.` + ); + } + return new Error(`${SIMULATE_METHOD} failed: ${message}`); +} + +function decodeRevert(call: SimulatedCall): string { + const data = call.error?.data ?? call.returnData; + if (data && data.startsWith(ERROR_STRING_SELECTOR)) { try { - await provider.send("web3_clientVersion", []); - return; + return ethers.utils.defaultAbiCoder.decode( + ["string"], + `0x${data.slice(10)}` + )[0]; } catch { - await new Promise((resolveWait) => setTimeout(resolveWait, 250)); + // Not a well-formed Error(string); fall through to the raw payload. } } - throw new Error( - `Timed out waiting ${timeoutMs}ms for the local Hardhat fork` - ); + return call.error?.message ?? `reverted with ${data || "no return data"}`; } -async function stopFork(child: ChildProcess) { - if (child.exitCode !== null) return; - child.kill("SIGTERM"); - await Promise.race([ - new Promise((resolveExit) => child.once("exit", () => resolveExit())), - new Promise((resolveTimeout) => - setTimeout(() => { - if (child.exitCode === null) child.kill("SIGKILL"); - resolveTimeout(); - }, 5_000) - ), - ]); +function assertCallsSucceeded(simulated: SimulatedCall[], calls: BatchCall[]) { + simulated.forEach((call, index) => { + if (call.status === "0x1") return; + throw new Error( + `Simulation failed at step ${index + 1} of ${simulated.length} (${ + calls[index].description + }): ${decodeRevert(call)}` + ); + }); } -export async function runLocalForkSimulation({ +/// Runs `calls` in order inside a single simulated block built on `blockTag`, as if the Safe had +/// sent each one. Nothing is broadcast and nothing persists past the response. +async function simulateCalls({ + provider, + safeAddress, + blockTag, + calls, +}: { + provider: ethers.providers.Provider; + safeAddress: string; + blockTag: string; + calls: BatchCall[]; +}): Promise { + let response: any; + try { + response = await (provider as ethers.providers.JsonRpcProvider).send( + SIMULATE_METHOD, + [ + { + blockStateCalls: [ + { + calls: calls.map((call) => ({ + from: safeAddress, + to: call.to, + value: hexValue(BigNumber.from(call.value)), + data: call.data, + })), + stateOverrides: { + [safeAddress]: { balance: SIMULATED_SAFE_BALANCE }, + }, + }, + ], + validation: false, + traceTransfers: false, + }, + blockTag, + ] + ); + } catch (error: any) { + throw simulationRpcError(error); + } + + const simulated: SimulatedCall[] | undefined = response?.[0]?.calls; + if (!Array.isArray(simulated) || simulated.length !== calls.length) { + throw new Error( + `${SIMULATE_METHOD} returned ${simulated?.length ?? "no"} results for ${ + calls.length + } calls` + ); + } + return simulated; +} + +/** + * Derive the Vault Value Checker bounds by simulating the proposal with `eth_simulateV1`, then + * replay the assembled batch to prove it passes `checkDelta`. + * + * One request behaves like a single throwaway fork: calls inside a block run in order and their + * writes stack, and a later block in the same request still sees them. Nothing survives the + * response, so the validation pass replays the whole batch from the top rather than continuing + * from the derive pass — and both passes are pinned to one block, because at `latest` the chain + * could advance between the two round-trips and validate against different state than we derived + * from. + * + * Failure modes worth knowing about: + * + * - `eth_simulateV1` is a hard dependency. A provider without it answers -32601, and this throws + * with instructions rather than proposing an unsimulated batch. `--skip-fork` with explicit + * expected values is the deliberate, visible escape hatch. + * - `validation: false` is load-bearing. Every call is spoofed `from` the Safe with no signature, + * nonce or balance check, and the balance override exists only to pay gas. Turning validation on + * rejects the whole approach. + * - Providers cap calls per block, blocks per request, and total simulated gas. A three-call batch + * measures ~1.3M gas, so a long `--moves` list is the realistic way to hit a cap. Caps surface as + * a request-level error rather than a per-call revert, so they land in `simulationRpcError` and + * never in `assertCallsSucceeded`. + * - The batch executes hours later, once two Safe owners have confirmed. For AMO strategies + * `expectedVaultChange` tracks pool state, so the tight per-vault defaults (100 OUSD, 1 OETH) can + * make `checkDelta` revert on execution if the pool has moved since. That fails safe, but it + * burns a Safe nonce and gas — pass `--vault-change-variance` for AMO moves. + * - This simulates the calls, not the Safe wrapper. Each one runs directly as the Safe, so a Safe + * Guard, `safeTxGas` exhaustion or a MultiSend-level failure is not exercised. Simulating the + * real `execTransaction` is possible — override the Safe's threshold slot to 1 and pass an + * approved-hash signature from an owner — but is deliberately out of scope here. + */ +export async function runSimulation({ config, + provider, blockNumber, safeAddress, vaultAddress, @@ -479,6 +567,7 @@ export async function runLocalForkSimulation({ log, }: { config: VaultConfig; + provider: ethers.providers.Provider; blockNumber: number; safeAddress: string; vaultAddress: string; @@ -492,164 +581,131 @@ export async function runLocalForkSimulation({ vaultChangeVariance?: string; log: Logger; }): Promise<{ derived: DerivedValues; checkerValues: CheckerValues }> { - const upstreamUrl = - process.env[config.providerEnv] || - (config.chainId === 1 ? process.env.MAINNET_PROVIDER_URL : undefined); - if (!upstreamUrl) { - throw new Error( - `${config.providerEnv} is required for local fork simulation` - ); - } + const vault = new ethers.utils.Interface(VAULT_ABI); + const checker = new ethers.utils.Interface(CHECKER_ABI); + const token = new ethers.utils.Interface(TOKEN_ABI); + const blockTag = hexValue(blockNumber); - const port = await getFreePort(); - const contractsDir = resolve(__dirname, "../.."); - const hardhatBin = resolve(contractsDir, "node_modules/.bin/hardhat"); - const env: NodeJS.ProcessEnv = { - ...process.env, - FORK: "true", - IS_TEST: "true", - FORK_NETWORK_NAME: config.chainId === 8453 ? "base" : "mainnet", - }; - delete env.HARDHAT_NETWORK; - delete env.NETWORK_NAME; - delete env.LOCAL_PROVIDER_URL; - if (config.chainId === 8453) env.BASE_BLOCK_NUMBER = String(blockNumber); - else env.BLOCK_NUMBER = String(blockNumber); - - const child = spawn( - hardhatBin, - ["node", "--hostname", "127.0.0.1", "--port", String(port), "--no-deploy"], - { cwd: contractsDir, env, stdio: ["ignore", "pipe", "pipe"] } - ); - let childOutput = ""; - const captureOutput = (chunk: Buffer) => { - childOutput = `${childOutput}${chunk.toString()}`.slice(-64 * 1024); - }; - child.stdout?.on("data", captureOutput); - child.stderr?.on("data", captureOutput); + // Rebase and snapshot exactly as the batch will, run the moves, then read the two values + // `checkDelta` compares. The snapshot is read back from the checker rather than recomputed, so + // the deltas are measured against the same storage the on-chain check will read. + const derivePlan: BatchCall[] = [ + { + to: vaultAddress, + value: "0", + data: vault.encodeFunctionData("rebase"), + description: "vault.rebase()", + }, + { + to: checkerAddress, + value: "0", + data: checker.encodeFunctionData("takeSnapshot"), + description: "valueChecker.takeSnapshot()", + }, + { + to: checkerAddress, + value: "0", + data: checker.encodeFunctionData("snapshots", [safeAddress]), + description: "valueChecker.snapshots(safe)", + }, + ...buildMoveCalls(vaultAddress, asset, moves), + { + to: vaultAddress, + value: "0", + data: vault.encodeFunctionData("totalValue"), + description: "vault.totalValue()", + }, + { + to: oToken, + value: "0", + data: token.encodeFunctionData("totalSupply"), + description: "oToken.totalSupply()", + }, + ]; - const provider = new ethers.providers.JsonRpcProvider( - `http://127.0.0.1:${port}`, - config.chainId + log.info( + `Simulating ${config.name} strategy moves at block ${blockNumber} with ${SIMULATE_METHOD}` ); - const forwardSignal = (signal: NodeJS.Signals) => { - child.kill(signal); - process.off("SIGINT", onSigint); - process.off("SIGTERM", onSigterm); - process.kill(process.pid, signal); - }; - const onSigint = () => forwardSignal("SIGINT"); - const onSigterm = () => forwardSignal("SIGTERM"); - process.once("SIGINT", onSigint); - process.once("SIGTERM", onSigterm); + const derivePass = await simulateCalls({ + provider, + safeAddress, + blockTag, + calls: derivePlan, + }); + assertCallsSucceeded(derivePass, derivePlan); - try { - log.info(`Starting ${config.name} fork simulation at block ${blockNumber}`); - await waitForFork(provider, child, 45_000); - await provider.send("hardhat_impersonateAccount", [safeAddress]); - await provider.send("hardhat_setBalance", [ - safeAddress, - parseEther("10").toHexString(), - ]); - - const safeSigner = provider.getSigner(safeAddress); - const vault = new Contract(vaultAddress, VAULT_ABI, safeSigner); - const checker = new Contract(checkerAddress, CHECKER_ABI, safeSigner); - const token = new Contract(oToken, TOKEN_ABI, provider); - - await (await vault.rebase(FORK_TRANSACTION_OVERRIDES)).wait(); - await (await checker.takeSnapshot(FORK_TRANSACTION_OVERRIDES)).wait(); - - for (const move of moves) { - if (move.kind === "deposit") { - await ( - await vault.depositToStrategy( - move.strategy, - [asset], - [move.amountUnits], - FORK_TRANSACTION_OVERRIDES - ) - ).wait(); - } else if (move.kind === "withdraw") { - await ( - await vault.withdrawFromStrategy( - move.strategy, - [asset], - [move.amountUnits], - FORK_TRANSACTION_OVERRIDES - ) - ).wait(); - } else { - await ( - await vault.withdrawAllFromStrategy( - move.strategy, - FORK_TRANSACTION_OVERRIDES - ) - ).wait(); - } - } + const snapshot = checker.decodeFunctionResult( + "snapshots", + derivePass[2].returnData + ); + const postVaultValue: BigNumber = vault.decodeFunctionResult( + "totalValue", + derivePass[derivePass.length - 2].returnData + )[0]; + const postTotalSupply: BigNumber = token.decodeFunctionResult( + "totalSupply", + derivePass[derivePass.length - 1].returnData + )[0]; + + const derived = calculateDerivedValues( + snapshot.vaultValue, + snapshot.totalSupply, + postVaultValue, + postTotalSupply + ); + const checkerValues = resolveCheckerValues({ + config, + derived, + expectedProfit, + profitVariance, + expectedVaultChange, + vaultChangeVariance, + skipFork: false, + }); - const snapshot = await checker.snapshots(safeAddress); - const postVaultValue = await vault.totalValue(); - const postTotalSupply = await token.totalSupply(); - const derived = calculateDerivedValues( + log.info( + `Simulated vault value: ${formatUnits( snapshot.vaultValue, + 18 + )} -> ${formatUnits(postVaultValue, 18)}; change ${formatUnits( + derived.vaultChange, + 18 + )}` + ); + log.info( + `Simulated token supply: ${formatUnits( snapshot.totalSupply, - postVaultValue, - postTotalSupply - ); - const checkerValues = resolveCheckerValues({ - config, - derived, - expectedProfit, - profitVariance, - expectedVaultChange, - vaultChangeVariance, - skipFork: false, - }); + 18 + )} -> ${formatUnits(postTotalSupply, 18)}; change ${formatUnits( + derived.supplyChange, + 18 + )}` + ); + log.info(`Simulation-derived profit: ${formatUnits(derived.profit, 18)}`); + + // Replay the batch that will actually be proposed, from the same block. A second request starts + // from unmodified state, so this re-runs rebase and takeSnapshot instead of continuing above. + const batch = buildBatchCalls({ + vaultAddress, + checkerAddress, + asset, + moves, + checkerValues, + }); + const validationPass = await simulateCalls({ + provider, + safeAddress, + blockTag, + calls: batch, + }); + assertCallsSucceeded(validationPass, batch); + const gasUsed = validationPass.reduce( + (total, call) => total.add(BigNumber.from(call.gasUsed)), + BigNumber.from(0) + ); + log.info( + `Value Checker validation succeeded; batch simulated in ${gasUsed.toString()} gas` + ); - log.info( - `Fork vault value: ${formatUnits( - snapshot.vaultValue, - 18 - )} -> ${formatUnits(postVaultValue, 18)}; change ${formatUnits( - derived.vaultChange, - 18 - )}` - ); - log.info( - `Fork token supply: ${formatUnits( - snapshot.totalSupply, - 18 - )} -> ${formatUnits(postTotalSupply, 18)}; change ${formatUnits( - derived.supplyChange, - 18 - )}` - ); - log.info(`Fork-derived profit: ${formatUnits(derived.profit, 18)}`); - - await ( - await checker.checkDelta( - checkerValues.expectedProfit, - checkerValues.profitVariance, - checkerValues.expectedVaultChange, - checkerValues.vaultChangeVariance, - FORK_TRANSACTION_OVERRIDES - ) - ).wait(); - log.info("Fork Value Checker validation succeeded"); - return { derived, checkerValues }; - } catch (error: any) { - const sanitized = childOutput - .replaceAll(upstreamUrl, "[redacted provider URL]") - .split("\n") - .slice(-12) - .join("\n"); - if (sanitized.trim()) log.warn(`Local fork output:\n${sanitized}`); - throw error; - } finally { - process.off("SIGINT", onSigint); - process.off("SIGTERM", onSigterm); - await stopFork(child); - } + return { derived, checkerValues }; } diff --git a/contracts/test/tasks/vault-strategy-moves.js b/contracts/test/tasks/vault-strategy-moves.js index bd23037e23..be5d781880 100644 --- a/contracts/test/tasks/vault-strategy-moves.js +++ b/contracts/test/tasks/vault-strategy-moves.js @@ -9,6 +9,7 @@ const { getVaultConfig, parseMoves, resolveCheckerValues, + runSimulation, } = require("../../tasks/lib/vaultStrategyMoves"); const { findIdenticalProposal, @@ -21,6 +22,68 @@ const strategyB = "0x0000000000000000000000000000000000000022"; const vaultAddress = "0x0000000000000000000000000000000000000033"; const checkerAddress = "0x0000000000000000000000000000000000000044"; const asset = "0x0000000000000000000000000000000000000055"; +const oToken = "0x0000000000000000000000000000000000000066"; +const safeAddress = "0x0000000000000000000000000000000000000077"; + +const silentLog = { info: () => {}, warn: () => {}, error: () => {} }; +const { defaultAbiCoder } = ethers.utils; + +const ok = (returnData = "0x") => ({ + status: "0x1", + returnData, + gasUsed: "0x5208", +}); +const reverted = (reason) => ({ + status: "0x0", + gasUsed: "0x5208", + returnData: `0x08c379a0${defaultAbiCoder + .encode(["string"], [reason]) + .slice(2)}`, +}); +const uint = (value) => defaultAbiCoder.encode(["uint256"], [value]); +const snapshotOf = (vaultValue, totalSupply) => + defaultAbiCoder.encode( + ["uint256", "uint256", "uint256"], + [vaultValue, totalSupply, 1] + ); + +// Each entry is one eth_simulateV1 response: either the array of per-call +// results, or a function that throws to model an RPC-level failure. +const stubProvider = (responses) => { + const requests = []; + return { + requests, + send: async (method, params) => { + requests.push({ method, params }); + const next = responses.shift(); + if (typeof next === "function") return next(); + return [{ calls: next }]; + }, + }; +}; + +const oneMove = [ + { + kind: "withdraw", + strategyIdentifier: "StrategyA", + strategy: strategyA, + amount: "3", + amountUnits: ethers.utils.parseUnits("3", 6), + }, +]; + +const simulationArgs = (provider) => ({ + config: getVaultConfig("OUSD", 1), + provider, + blockNumber: 123, + safeAddress, + vaultAddress, + checkerAddress, + asset, + oToken, + moves: oneMove, + log: silentLog, +}); describe("Talos vault strategy moves", () => { it("parses mixed movements without changing their order", () => { @@ -288,6 +351,95 @@ describe("Talos vault strategy moves", () => { ).to.throw("already executed in 0xonchain"); }); + it("derives checker values from the simulation and pins both passes to one block", async () => { + const provider = stubProvider([ + // derive pass: rebase, takeSnapshot, snapshots, move, totalValue, totalSupply + [ + ok(), + ok(), + ok( + snapshotOf( + ethers.utils.parseUnits("1000", 18), + ethers.utils.parseUnits("990", 18) + ) + ), + ok(), + ok(uint(ethers.utils.parseUnits("1002", 18))), + ok(uint(ethers.utils.parseUnits("990", 18))), + ], + // validation pass: rebase, takeSnapshot, move, checkDelta + [ok(), ok(), ok(), ok()], + ]); + + const { derived, checkerValues } = await runSimulation( + simulationArgs(provider) + ); + + expect(ethers.utils.formatUnits(derived.vaultChange, 18)).to.equal("2.0"); + expect(derived.supplyChange.isZero()).to.equal(true); + expect(ethers.utils.formatUnits(derived.profit, 18)).to.equal("2.0"); + expect(ethers.utils.formatUnits(checkerValues.expectedProfit, 18)).to.equal( + "2.0" + ); + + expect(provider.requests).to.have.length(2); + provider.requests.forEach((request) => { + expect(request.method).to.equal("eth_simulateV1"); + expect(request.params[1]).to.equal("0x7b"); + expect(request.params[0].validation).to.equal(false); + expect( + request.params[0].blockStateCalls[0].calls.every( + (call) => call.from === safeAddress + ) + ).to.equal(true); + }); + }); + + it("attributes a reverted simulation step to its batch description", async () => { + const provider = stubProvider([ + [ + ok(), + ok(), + ok(snapshotOf(1, 1)), + reverted("Not enough assets available"), + ok(uint(1)), + ok(uint(1)), + ], + ]); + + let error; + try { + await runSimulation(simulationArgs(provider)); + } catch (err) { + error = err; + } + expect(error, "expected the simulation to throw").to.not.equal(undefined); + expect(error.message).to.contain("step 4 of 6"); + expect(error.message).to.contain("withdraw:StrategyA:3"); + expect(error.message).to.contain("Not enough assets available"); + }); + + it("explains how to proceed when the RPC cannot simulate", async () => { + const provider = stubProvider([ + () => { + const err = new Error("the method eth_simulateV1 does not exist"); + err.error = { code: -32601, message: "Method not found" }; + throw err; + }, + ]); + + let error; + try { + await runSimulation(simulationArgs(provider)); + } catch (err) { + error = err; + } + expect(error, "expected the simulation to throw").to.not.equal(undefined); + expect(error.message).to.contain("does not support eth_simulateV1"); + expect(error.message).to.contain("--skip-fork"); + expect(error.message).to.contain("--expected-profit"); + }); + it("normalizes an ethers signMessage signature for Safe eth_sign", async () => { const wallet = ethers.Wallet.createRandom(); const hash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes("safe hash"));