diff --git a/contracts/README.md b/contracts/README.md index 977b97a6e7..2642878474 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -317,6 +317,147 @@ Building the runner image installs the optional `@oplabs/talos-client` peer dependency from GitHub Packages. Set `TALOS_PACKAGE_TOKEN` to a PAT with `read:packages` access before running `docker compose build`. +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. + +To use a [Relayer](https://defender.openzeppelin.com/v2/#/manage/relayers) account, first log into Defender and create an API key for the account you want to use. Use the generated API key and secret to set the `DEFENDER_API_KEY` and `DEFENDER_API_SECRET` environment variables. + +``` +export DEFENDER_API_KEY= +export DEFENDER_API_SECRET= +``` + +Once you have finished sending your transactions, the API key for hte Relayer account should be deleted in Defender and the environment variables unset. + +``` +unset DEFENDER_API_KEY +unset DEFENDER_API_SECRET +``` + +### Deploying Defender Actions + +Actions are used to run operational jobs are specific times or intervals. + +[rollup](https://rollupjs.org/) is used to bundle Actions source code in +[/scripts/defender-actions](./scripts/defender-actions) into a single file that can be uploaded to Defender. The +implementation was based off +[Defender Actions example using Rollup](https://github.com/OpenZeppelin/defender-autotask-examples/tree/master/rollup). +The rollup config is in [/scripts/defender-actions/rollup.config.cjs](./scripts/defender-actions/rollup.config.cjs). The +outputs are written to task specific folders under [/scripts/defender-actions/dist](./scripts/defender-actions/dist/). + +The [defender-autotask CLI](https://www.npmjs.com/package/@openzeppelin/defender-autotask-client) is used to upload the +Action code to Defender. For this to work, a Defender Team API key with `Manage Actions` capabilities is needed. This +can be generated by a Defender team admin under the `Manage` tab on the top right of the UI and then `API Keys` on the +left menu. Best to unselect all capabilities except `Manage Actions`. + +Save the Defender Team API key and secret to your `.env` file. + +``` +# Open Zeppelin Defender Team API key +DEFENDER_TEAM_KEY= +DEFENDER_TEAM_SECRET= +``` + +The following will bundle the Actions code ready for upload. + +``` +cd contracts +pnpm rollup -c ./scripts/defender-actions/rollup.config.cjs +``` + +If you get error like the below, you will need to install `@rollup/rollup-darwin-x64` + +``` +Error: Cannot find module @rollup/rollup-darwin-x64. npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). Please try `npm i` again after removing both package-lock.json and node_modules directory. +``` + +If you are on Apple Silicon (M1/M2/M3) and you node is emulated x64, then the following will return `x64` rather than `arm64`. + +``` +node -p "process.arch" +``` + +If x64, run the following to install the required rollup package. + +``` +pnpm add -D @rollup/rollup-darwin-x64 +``` + +The following will upload the different Action bundles to Defender. + +```sh +cd contracts +pnpm rollup -c ./scripts/defender-actions/rollup.config.cjs + +# Set the DEFENDER_TEAM_KEY and DEFENDER_TEAM_SECRET environment variables in the .env file + +# Set the DEBUG environment variable to oeth* for the Defender Action +pnpm hardhat setActionVars --id e2929f53-db56-49b2-b054-35f7df7fc4fb +pnpm hardhat setActionVars --id 6e4f764d-4126-45a5-b7d9-1ab90cd3ffd6 +pnpm hardhat setActionVars --id 84988850-6816-4074-8e7b-c11cb2b32e7e +pnpm hardhat setActionVars --id f92ea662-fc34-433b-8beb-b34e9ab74685 +pnpm hardhat setActionVars --id b1d831f1-29d4-4943-bb2e-8e625b76e82c +pnpm hardhat setActionVars --id 6567d7c6-7ec7-44bd-b95b-470dd1ff780b +pnpm hardhat setActionVars --id 6a633bb0-aff8-4b37-aaae-b4c6f244ed87 +pnpm hardhat setActionVars --id 076c59e4-4150-42c7-9ba0-9962069ac353 +pnpm hardhat setActionVars --id ca80b55c-f3f7-4e03-a2f5-ea444645f8d9 +pnpm hardhat setActionVars --id f74f24b4-d98b-4181-89cc-6608369b6f91 +pnpm hardhat setActionVars --id aa194c13-0dbf-49d2-8e87-70e61f3d71a8 +pnpm hardhat setActionVars --id 65b53496-e426-4850-8349-059e63eb2120 +pnpm hardhat setActionVars --id a4f8ca5f-7144-469b-b84a-58b30fed72ce +pnpm hardhat setActionVars --id cbddb98e-a0f6-4aa8-bd73-20aa8e81d704 + +# Mainnet +pnpm hardhat updateAction --id e2929f53-db56-49b2-b054-35f7df7fc4fb --file doAccounting +pnpm hardhat updateAction --id 6e4f764d-4126-45a5-b7d9-1ab90cd3ffd6 --file harvest +pnpm hardhat updateAction --id 84988850-6816-4074-8e7b-c11cb2b32e7e --file sonicRequestWithdrawal +pnpm hardhat updateAction --id f92ea662-fc34-433b-8beb-b34e9ab74685 --file sonicClaimWithdrawals +pnpm hardhat updateAction --id b1d831f1-29d4-4943-bb2e-8e625b76e82c --file claimBribes +pnpm hardhat updateAction --id 6567d7c6-7ec7-44bd-b95b-470dd1ff780b --file manageBribeOnSonic +pnpm hardhat updateAction --id 6a633bb0-aff8-4b37-aaae-b4c6f244ed87 --file managePassThrough +pnpm hardhat updateAction --id 076c59e4-4150-42c7-9ba0-9962069ac353 --file manageBribes +pnpm hardhat updateAction --id ca80b55c-f3f7-4e03-a2f5-ea444645f8d9 --file ousdRebalancer +pnpm hardhat updateAction --id f74f24b4-d98b-4181-89cc-6608369b6f91 --file updateVotemarketEpochs +pnpm hardhat updateAction --id aa194c13-0dbf-49d2-8e87-70e61f3d71a8 --file manageMerklBribes # Mainnet +pnpm hardhat updateAction --id 65b53496-e426-4850-8349-059e63eb2120 --file manageMerklBribes # Base +pnpm hardhat updateAction --id a4f8ca5f-7144-469b-b84a-58b30fed72ce --file claimSSVRewards + +# These are Base -> Mainnet & Mainnet -> Base actions +# they share the codebase. The direction of relaying attestations is defined by the +# network of the relayer that is attached to the action +pnpm hardhat updateAction --id bb43e5da-f936-4185-84da-253394583665 --file crossChainRelay +pnpm hardhat updateAction --id e571409b-5399-48e4-bfb2-50b7af9903aa --file crossChainRelay + +# One-off action for backfilling a missing tx-level key in Defender KV store +pnpm hardhat updateAction --id cbddb98e-a0f6-4aa8-bd73-20aa8e81d704 --file crossChainRelayBackfillTx + +# OUSD Ethereum -> HyperEVM actions +pnpm hardhat updateAction --id 0b852456-96a0-4f1d-9d6c-39e1c6ae9dfc --file crossChainRelayHyperEVM +pnpm hardhat updateAction --id 65f04f74-8da7-4fc5-94b3-96be31bac03b --file crossChainRelayHyperEVM + +``` + +To backfill a missing tx-level cross-chain relay key, set +`HARDCODED_TRANSACTION_ID` and `HARDCODED_DESTINATION_CHAIN_ID` in +`scripts/defender-actions/crossChainRelayBackfillTx.js`, then bundle/upload and +run the `crossChainRelayBackfillTx` Action manually in Defender. + +This stores both tx-level keys with value `processed` in Defender's key-value +store: + +- `cctp_message_` (legacy format) +- `cctp_message__` (current + format from `tasks/crossChain.js`, where domain id is derived from the + hardcoded destination chain id: mainnet `1 -> 0`, base `8453 -> 6`, hyperevm + `999 -> 19`) + +`rollup` can be installed globally to avoid the `npx` prefix. Signer construction (KMS via `utils/signersNoHardhat.js`, `DEPLOYER_PK` / `GOVERNOR_PK` fallbacks, and `IMPERSONATE`) stays exactly as described in the sections above. The library only handles the nonce wrap; it does not construct diff --git a/contracts/dev.env b/contracts/dev.env index ce46cc8e2e..8472b25de3 100644 --- a/contracts/dev.env +++ b/contracts/dev.env @@ -108,6 +108,8 @@ SONIC_PROVIDER_URL=https://rpc.soniclabs.com # 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= # 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 2bb7def52a..41825f539f 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 f535f1dc02..ef4f557632 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 exec tsx tasks/run.ts permissionedRebase --network base', '15 10,22 * * *', 'UTC', false, NULL), ('origin-dollar', 'module_rebase_sonic', 'cd /app && pnpm exec tsx tasks/run.ts permissionedRebase --network sonic', '15 10,22 * * *', 'UTC', false, NULL), ('origin-dollar', 'ousd_rebalancer', 'cd /app && pnpm exec tsx tasks/run.ts 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 exec tsx tasks/run.ts 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 exec tsx tasks/run.ts 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 exec tsx tasks/run.ts queueGovernorSixProposal --network mainnet', '0 0 1 1 *', 'UTC', false, 'Manual: add --propid then Run now'), ('origin-dollar', 'execute_proposal', 'cd /app && pnpm exec tsx tasks/run.ts executeGovernorSixProposal --network mainnet', '0 0 1 1 *', 'UTC', false, 'Manual: add --propid then Run now') ON CONFLICT (product, name) DO UPDATE SET command = EXCLUDED.command; diff --git a/contracts/package.json b/contracts/package.json index de35f16dd9..bec98138b6 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -143,6 +143,9 @@ "@lodestar/state-transition": "^1.44.0", "@lodestar/types": "^1.44.0", "@lodestar/utils": "^1.44.0", + "@safe-global/api-kit": "^5.0.1", + "@safe-global/protocol-kit": "^8.0.3", + "@safe-global/types-kit": "^4.0.1", "@types/pg": "^8.20.0", "origin-morpho-utils": "^0.1.1", "pg": "^8.20.0", diff --git a/contracts/pnpm-lock.yaml b/contracts/pnpm-lock.yaml index 5816e62f05..bba118a874 100644 --- a/contracts/pnpm-lock.yaml +++ b/contracts/pnpm-lock.yaml @@ -37,6 +37,15 @@ importers: '@lodestar/utils': specifier: ^1.44.0 version: 1.44.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) + '@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) + '@safe-global/types-kit': + specifier: ^4.0.1 + version: 4.0.1(typescript@5.9.3) '@types/pg': specifier: ^8.20.0 version: 8.20.0 @@ -1438,6 +1447,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'} @@ -1614,6 +1629,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==} @@ -2444,6 +2475,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'} @@ -4595,6 +4630,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'} @@ -4844,6 +4887,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'} @@ -5073,6 +5123,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'} @@ -5660,6 +5715,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.7.3: resolution: {integrity: sha512-y2i2IW0MfSqFc1JBhBSQ59Ts9xE30hhxSmLS13jLKWzie24/An5dnoGarp2rFAy20tevJu1zJVPYrEl14jiL5w==} engines: {node: '>=8.0.0'} @@ -5875,6 +5938,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==} @@ -8066,6 +8141,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 @@ -8210,6 +8297,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)': + dependencies: + '@safe-global/protocol-kit': 8.0.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@safe-global/types-kit': 4.0.1(typescript@5.9.3) + node-fetch: 2.7.0 + viem: 2.55.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + 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)': + 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) + abitype: 1.2.3(typescript@5.9.3) + semver: 7.8.5 + viem: 2.55.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + 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)': + dependencies: + abitype: 1.2.3(typescript@5.9.3) + transitivePeerDependencies: + - typescript + - zod + '@scure/base@1.1.9': {} '@scure/base@1.2.6': {} @@ -9307,6 +9437,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: @@ -11313,6 +11450,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: @@ -11868,6 +12009,21 @@ snapshots: transitivePeerDependencies: - zod + ox@0.14.30(typescript@5.9.3): + 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) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + p-cancelable@1.1.0: {} p-cancelable@2.1.1: {} @@ -12082,6 +12238,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 @@ -12343,6 +12507,8 @@ snapshots: semver@7.7.3: {} + semver@7.8.5: {} + send@0.19.2: dependencies: debug: 2.6.9 @@ -13091,6 +13257,23 @@ snapshots: - utf-8-validate - zod + viem@2.55.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10): + 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) + 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) + 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.7.3(bufferutil@4.1.0)(utf-8-validate@5.0.10): dependencies: '@types/node': 12.20.55 @@ -13398,6 +13581,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..a9100dd43c --- /dev/null +++ b/contracts/tasks/actions/proposeVaultStrategyMoves.ts @@ -0,0 +1,300 @@ +import { Contract } from "ethers"; +import { formatUnits, getAddress } from "ethers/lib/utils"; + +import addresses from "../../utils/addresses"; +import { action, types } from "../lib/action"; +import { getContract } from "../lib/contracts"; +import { + assertNonceStillAvailable, + assertRegisteredDelegate, + createSafeClients, + createSafeTransaction, + estimateSafeTransaction, + findIdenticalProposal, + proposeSafeTransaction, + resolveNonce, + safeTransactionUrl, + toMetaTransactions, +} from "../lib/safeProposal"; +import { + TOKEN_ABI, + VAULT_ABI, + buildBatchCalls, + getVaultConfig, + parseMoves, + resolveCheckerValues, + resolveMoves, + 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: + "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 simulation; 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 [vaultDeployment, checkerDeployment] = await Promise.all([ + deploymentAddress(config.vaultDeployment), + deploymentAddress(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); + const checkerAddress = getAddress(checkerDeployment); + 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: deploymentAddress, + 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 simulation"); + } else { + // Pinned once so both simulation passes see identical state. + const blockNumber = await provider.getBlockNumber(); + ({ checkerValues } = await runSimulation({ + config, + provider, + 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..63b333075d --- /dev/null +++ b/contracts/tasks/lib/vaultStrategyMoves.ts @@ -0,0 +1,711 @@ +import { BigNumber, Contract, ethers } from "ethers"; +import { + formatUnits, + getAddress, + hexValue, + 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; +} + +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", + }, + OETH: { + name: "OETH", + chainId: 1, + vaultDeployment: "OETHVaultProxy", + checkerDeployment: "OETHVaultValueChecker", + profitVariance: "1", + vaultChangeVariance: "1", + }, + SuperOETH: { + name: "SuperOETH", + chainId: 8453, + vaultDeployment: "OETHBaseVaultProxy", + checkerDeployment: "OETHVaultValueChecker", + profitVariance: "1", + vaultChangeVariance: "10", + }, +}; + +const DECIMAL_VALUE = /^(?:0|[1-9]\d*)(?:\.\d+)?$/; +const SIGNED_DECIMAL_VALUE = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/; + +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(); + 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; +} + +/// 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, + 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()", + }, + ...buildMoveCalls(vaultAddress, asset, moves), + ]; + + calls.push({ + to: checkerAddress, + value: "0", + data: checker.encodeFunctionData("checkDelta", [ + checkerValues.expectedProfit, + checkerValues.profitVariance, + checkerValues.expectedVaultChange, + checkerValues.vaultChangeVariance, + ]), + description: "valueChecker.checkDelta(...)", + }); + return calls; +} + +interface SimulatedCall { + status: string; + returnData: string; + gasUsed: string; + error?: { code?: number; message?: string; data?: string }; +} + +/// 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 { + return ethers.utils.defaultAbiCoder.decode( + ["string"], + `0x${data.slice(10)}` + )[0]; + } catch { + // Not a well-formed Error(string); fall through to the raw payload. + } + } + return call.error?.message ?? `reverted with ${data || "no return data"}`; +} + +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)}` + ); + }); +} + +/// 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, + checkerAddress, + asset, + oToken, + moves, + expectedProfit, + profitVariance, + expectedVaultChange, + vaultChangeVariance, + log, +}: { + config: VaultConfig; + provider: ethers.providers.Provider; + 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 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); + + // 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()", + }, + ]; + + log.info( + `Simulating ${config.name} strategy moves at block ${blockNumber} with ${SIMULATE_METHOD}` + ); + const derivePass = await simulateCalls({ + provider, + safeAddress, + blockTag, + calls: derivePlan, + }); + assertCallsSucceeded(derivePass, derivePlan); + + 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, + }); + + 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, + 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` + ); + + return { derived, checkerValues }; +} diff --git a/contracts/test/tasks/vault-strategy-moves.js b/contracts/test/tasks/vault-strategy-moves.js new file mode 100644 index 0000000000..be5d781880 --- /dev/null +++ b/contracts/test/tasks/vault-strategy-moves.js @@ -0,0 +1,457 @@ +const { expect } = require("chai"); +const { BigNumber, ethers } = require("ethers"); + +const { + CHECKER_ABI, + VAULT_ABI, + buildBatchCalls, + calculateDerivedValues, + getVaultConfig, + parseMoves, + resolveCheckerValues, + runSimulation, +} = 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"; +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", () => { + 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("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")); + 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); + }); +});