diff --git a/.changeset/fix-cjs-abi-exports.md b/.changeset/fix-cjs-abi-exports.md new file mode 100644 index 0000000..84aad6e --- /dev/null +++ b/.changeset/fix-cjs-abi-exports.md @@ -0,0 +1,5 @@ +--- +"@reserve-protocol/sdk": patch +--- + +Fix the CommonJS entry: `require("@reserve-protocol/sdk")` exported `{ default: abi }` instead of the ABI array for `folioV6Abi`, `folioArtifactAbi`, `folioDeployerV6Abi`, `folioVersionRegistryAbi`, `dtfIndexAbi`, `dtfIndexAbiV1`, `dtfIndexAbiV2`, `dtfIndexAbiV4`, `dtfIndexGovernanceAbi`, `dtfIndexStakingVaultAbi` and `timelockAbi`, so encoding with them failed (`abi.filter is not a function`). ESM was unaffected. The ABI modules no longer carry a default export, and `check:package` now packs the tarball and loads it through both `import` and `require` in CI and the release gate. diff --git a/.changeset/fix-cjs-dtf-catalog-export.md b/.changeset/fix-cjs-dtf-catalog-export.md new file mode 100644 index 0000000..4792d31 --- /dev/null +++ b/.changeset/fix-cjs-dtf-catalog-export.md @@ -0,0 +1,6 @@ +--- +"@reserve-protocol/dtf-catalog": minor +"@reserve-protocol/sdk": patch +--- + +Fix `require("@reserve-protocol/sdk").dtfCatalog`: the CommonJS entry re-exported the catalog's module namespace (`{ default, indexDtfs, ... }`) instead of the chain-keyed catalog, because the bundler's interop for a re-exported default hands out `module.exports`. The catalog now also exports the combined catalog by name (`dtfCatalog`, same object as the default export), and the SDK re-exports that. `check:package` now compares every export's value between the ESM and CommonJS entries, not just its kind. diff --git a/.changeset/fix-folio-v6-rebalances.md b/.changeset/fix-folio-v6-rebalances.md new file mode 100644 index 0000000..3adc01c --- /dev/null +++ b/.changeset/fix-folio-v6-rebalances.md @@ -0,0 +1,6 @@ +--- +"@reserve-protocol/sdk": patch +"@reserve-protocol/react-sdk": patch +--- + +Generate and export the Folio v6, FolioDeployer v6, and FolioVersionRegistry ABIs from pinned `reserve-index-dtf` artifacts, and encode v6 basket proposals with the expected rebalance nonce and required execution deadline. V6 fee-recipient calls now require both mutable and immutable recipient tables; higher-level revenue proposals reject v6 until they can preserve the immutable table. diff --git a/.changeset/fix-index-voting-snapshot.md b/.changeset/fix-index-voting-snapshot.md new file mode 100644 index 0000000..84c059e --- /dev/null +++ b/.changeset/fix-index-voting-snapshot.md @@ -0,0 +1,5 @@ +--- +"@reserve-protocol/sdk": patch +--- + +Read standard Index DTF proposal voting power at the proposal snapshot itself, matching the governor and including delegation checkpoints created at that timepoint. Preserve clamping for snapshots at or after the current clock. diff --git a/.changeset/index-dtf-deploy-event-emitter.md b/.changeset/index-dtf-deploy-event-emitter.md new file mode 100644 index 0000000..dcc5443 --- /dev/null +++ b/.changeset/index-dtf-deploy-event-emitter.md @@ -0,0 +1,10 @@ +--- +"@reserve-protocol/sdk": minor +"@reserve-protocol/react-sdk": minor +--- + +Index DTF: deploy event extraction only trusts the deployer. + +- Breaking: `extractIndexDtfDeployedAddress(logs, target)` takes the deploy target (`{ chainId, version, deployer? }`, the same object the deploy builders take) and only reads `FolioDeployed` / `GovernedFolioDeployed` emitted by that version's deployer. Before, any emitter was accepted, so a basket token could emit a lookalike event during `transferFrom` and the SDK returned the attacker's address. +- Breaking: `extractIndexDtfDeployedStakingTokenAddress(logs, { chainId })` only reads `DeployedGovernedStakingToken` from the chain's `INDEX_DTF_GOVERNANCE_DEPLOYER_ADDRESS`. +- Both throw `RECORD_NOT_FOUND` without the deployer's event and `INVALID_RESPONSE` when the deployer's events name more than one address. diff --git a/.changeset/index-dtf-v6-basket-allowlist.md b/.changeset/index-dtf-v6-basket-allowlist.md new file mode 100644 index 0000000..ab3bd90 --- /dev/null +++ b/.changeset/index-dtf-v6-basket-allowlist.md @@ -0,0 +1,5 @@ +--- +"@reserve-protocol/sdk": patch +--- + +Index DTF: the Folio 6.0 basket proposal builder reads the trade allowlist and throws `INVALID_INPUT` (meta `tokens`) when enforcement is on and a rebalance token is missing from it, instead of building a proposal whose `startRebalance` reverts `Folio__TokenNotAllowlisted` on execution. diff --git a/.changeset/index-dtf-v6-deployer.md b/.changeset/index-dtf-v6-deployer.md new file mode 100644 index 0000000..b28370a --- /dev/null +++ b/.changeset/index-dtf-v6-deployer.md @@ -0,0 +1,8 @@ +--- +"@reserve-protocol/sdk": minor +--- + +Index DTF: Folio 6.0 deployments. + +- `prepareIndexDtfDeploy`, `prepareIndexDtfDeployGoverned` and their plans take `version`. `"5.0.0"` keeps the registered per-chain deployer and the existing shapes. `"6.0.0"` deploys through `INDEX_DTF_V6_DEPLOYER_ADDRESS` and takes v6 additional details (`maxAuctionLength`, `feeRecipients`, `immutableFeeRecipients`, `tvlFee`, `mintFee`, `selfFee`, `mandate`) and, for governed deploys, one optimistic `governance` table instead of owner/trading pairs. +- Deploy plans approve the deployer they resolved. `getIndexDtfDeployerAddress` resolves the target for `{ chainId, version, deployer? }`. Both deploy builders now return `IndexDtfCall`, so `call.contract` is typed against the generic `Abi`. diff --git a/.changeset/index-dtf-v6-end-rebalance-nonce.md b/.changeset/index-dtf-v6-end-rebalance-nonce.md new file mode 100644 index 0000000..2572e92 --- /dev/null +++ b/.changeset/index-dtf-v6-end-rebalance-nonce.md @@ -0,0 +1,9 @@ +--- +"@reserve-protocol/sdk": minor +"@reserve-protocol/react-sdk": minor +--- + +Index DTF: Folio 6.0 ABIs re-synced to the deployed 6.0.0 (`reserve-index-dtf@7d97c80`, identical to the explorer-verified implementation on mainnet, Base and BSC). + +- Breaking for 6.0.0: `prepareIndexDtfEndRebalance` / `ref.prepareEndRebalance` require `rebalanceNonce` and encode `endRebalance(uint256)`; the previous nonce-less call reverted on every real 6.0.0 Folio. 5.0.0 still encodes `endRebalance()`. New `PrepareIndexDtfEndRebalanceParams` type. +- `folioV6Abi` / `folioArtifactAbi` now carry `endRebalance(uint256 rebalanceNonce)`. A unit test compares the generated Folio and FolioDeployer ABIs item for item with the Base Blockscout verified ABIs, so drift from the deployed contracts fails CI. diff --git a/.changeset/index-dtf-v6-open-auction.md b/.changeset/index-dtf-v6-open-auction.md new file mode 100644 index 0000000..a4a4362 --- /dev/null +++ b/.changeset/index-dtf-v6-open-auction.md @@ -0,0 +1,5 @@ +--- +"@reserve-protocol/sdk": patch +--- + +Encode Folio 6.0 `openAuction` calls with the required trailing auction length while preserving the existing Folio 5.0 call shape. diff --git a/.changeset/index-dtf-v6-real-deployer.md b/.changeset/index-dtf-v6-real-deployer.md new file mode 100644 index 0000000..27a0243 --- /dev/null +++ b/.changeset/index-dtf-v6-real-deployer.md @@ -0,0 +1,8 @@ +--- +"@reserve-protocol/sdk": minor +--- + +Index DTF: Folio 6.0 deploys target the real 6.0.0 `FolioDeployer`. + +- New `INDEX_DTF_V6_DEPLOYER_ADDRESS` per chain (mainnet `0x2B1C…7392`, Base `0x4c89…cF67`, BSC `0x9837…1963`). `"6.0.0"` deploys go there by default, whether or not the chain's `FolioVersionRegistry` has registered 6.0.0 yet; `deployer` is an optional override for fork and sandbox deployers. +- Breaking: `prepareIndexDtfDeployAssetApproval(s)` require `version` and default the spender to that version's deployer, so a 6.0.0 deploy never approves the 5.0.0 deployer by default. `deployer` still overrides the spender. diff --git a/.changeset/index-dtf-v6-settings.md b/.changeset/index-dtf-v6-settings.md new file mode 100644 index 0000000..c651491 --- /dev/null +++ b/.changeset/index-dtf-v6-settings.md @@ -0,0 +1,9 @@ +--- +"@reserve-protocol/sdk": minor +--- + +Index DTF: Folio 6.0 settings and revenue proposals. + +- `buildIndexDtfSettingsProposal` accepts `selfFee` (percent kept for holders), `tradeAllowlist: { enabled?, add?, remove? }` and, for revenue changes on 6.0.0, `immutableFeeRecipients`. When the table is omitted the client-bound builder reads it from RPC. The mutable shares are scaled into whatever the immutable table leaves; v5 DTFs reject the v6-only fields by name. +- `prepareIndexDtfSetFeeRecipients` on 6.0.0 sorts both tables by address and rejects tables that do not total 100%, mirroring FolioLib so a bad table fails at build time. Folio 6.0 deploys apply the same check. +- New `sortIndexDtfFeeRecipients`, `scaleIndexDtfFeeRecipients`, `assertIndexDtfFeeRecipientTables` helpers. diff --git a/.changeset/index-dtf-v6-state-reads.md b/.changeset/index-dtf-v6-state-reads.md new file mode 100644 index 0000000..918fc71 --- /dev/null +++ b/.changeset/index-dtf-v6-state-reads.md @@ -0,0 +1,9 @@ +--- +"@reserve-protocol/sdk": minor +"@reserve-protocol/react-sdk": minor +--- + +Index DTF: Folio 6.0 state reads and the version registry. + +- `getIndexDtfSelfFee`, `getIndexDtfImmutableFeeRecipients` (the full table `setFeeRecipients` must receive back), `getIndexDtfTradeAllowlist` (`{ enabled, tokens }`) and `getIndexDtfIsTokenAllowlisted` read the v6 contract from RPC at one pinned block; on a v5 proxy they throw (the immutable-table read probes `folioFeeForSelf` first instead of reporting an empty table). Exposed as `sdk.index.get*`, on the DTF ref, and as `useIndexDtf*` hooks. +- `getIndexDtfLatestVersion` and `getIndexDtfVersionDeployment` read `FolioVersionRegistry` (`INDEX_DTF_VERSION_REGISTRY_ADDRESS` per chain, overridable with `registry`); `getIndexDtfVersionHash` mirrors the registry's `keccak256(abi.encodePacked(version))`. Hooks sit at the static cache tier. diff --git a/.changeset/index-dtf-v6-subgraph-fields.md b/.changeset/index-dtf-v6-subgraph-fields.md new file mode 100644 index 0000000..f3c97fa --- /dev/null +++ b/.changeset/index-dtf-v6-subgraph-fields.md @@ -0,0 +1,13 @@ +--- +"@reserve-protocol/sdk": minor +"@reserve-protocol/react-sdk": minor +--- + +Index DTF: Folio 6.0 fields from the subgraph. + +- `IndexDtf` gains `version`, `rebalance.maxAuctionLength`, `rebalance.tradeAllowlist`, `fees.immutableRecipients`, `fees.selfFee` and `financials.selfRevenue` (absent, empty or zero before 6.0 and for grafted DTFs whose fields are null; `tradeAllowlist` is left out unless both the flag and the token list are indexed). `financials.totalRevenue` is protocol + governance + external + self; revenue fields are raw 18-decimal share amounts. The React SDK's flat `IndexDtfData` gains `selfRevenue`. +- `getIndexDtfRevenue` folds the immutable table and the holders' share into `effectiveDistribution` (`holders.percentage`) in the order Folio pays: the DAO fee first, the self fee keeps its share of the rest for holders, and the mutable and immutable tables split what is left (DAO 50%, self fee 25%, tables 60/40 → mutable 22.5%, immutable 15%, holders 12.5%). With both tables empty the DAO gets the recipients' pool, as `distributeFees` does. +- Breaking: `getIndexDtfEffectiveRevenueDistribution(fees, platformFee)` takes `{ recipients, immutableRecipients, selfFee }` instead of the recipients array. +- Breaking for code that builds these objects by hand: `Fees` requires `immutableRecipients` and `selfFee`, `Financials` requires `selfRevenue`, and `IndexDtfRevenueDistribution` requires `holders`. + +Rollout blocker: `GetIndexDTF` now needs the index subgraph 1.11.1 schema. Against a 1.10.2 endpoint every Index DTF query fails, so this release must not be published (or Register pointed at it) before all three `prod` endpoints (mainnet, Base, BSC) serve 1.11.1. `release:ci` enforces it: `graphql:codegen:check` keeps checking Base `prod` by default (`INDEX_DTF_SUBGRAPH_SCHEMA=<.../dtf-index-base/1.11.1-test/gn>` checks the candidate), and the new `check:subgraph-endpoints` validates the shipped documents against every configured endpoint. diff --git a/.changeset/index-dtf-v6-upgrade.md b/.changeset/index-dtf-v6-upgrade.md new file mode 100644 index 0000000..9195128 --- /dev/null +++ b/.changeset/index-dtf-v6-upgrade.md @@ -0,0 +1,5 @@ +--- +"@reserve-protocol/sdk": minor +--- + +Index DTF: 5.0.0 → 6.0.0 upgrade proposals. `buildIndexDtfUpgradeToV6Proposal` (`sdk.index.buildUpgradeToV6Proposal`, `ref.buildUpgradeToV6Proposal`) builds the spell migration against the DTF's admin governance: selector-registry swap of `startRebalance` for optimistic governors, `transferOwnership(spell)` on the proxy admin, then `cast`. `buildIndexDtfUpgradeToV6Calls` is the pure form (`selectorRegistry` present means optimistic); the description defaults to "Upgrade to Folio 6.0.0" and the DTF can be injected with `dtf`; `INDEX_DTF_START_REBALANCE_SELECTOR` and `upgradeSpell600Abi` are exported. diff --git a/.changeset/index-dtf-v6-write-contract.md b/.changeset/index-dtf-v6-write-contract.md new file mode 100644 index 0000000..ea2b9c0 --- /dev/null +++ b/.changeset/index-dtf-v6-write-contract.md @@ -0,0 +1,11 @@ +--- +"@reserve-protocol/sdk": minor +"@reserve-protocol/react-sdk": minor +--- + +Index DTF: every Folio write builder takes `version`. + +- `prepareIndexDtfBid`, `prepareIndexDtfCloseAuction`, `prepareIndexDtfEndRebalance`, `prepareIndexDtfOpenAuctionUnrestricted`, `prepareIndexDtfDistributeFees`, `prepareIndexDtfMint` (and its plan) and `prepareIndexDtfRedeem` now require `version` (`"5.0.0"` | `"6.0.0"`) and reject anything else with `SdkError(INVALID_INPUT)`. Their calldata is byte-identical across both versions; the ABI attached to the call follows the version. +- Breaking for ref callers: `dtf.prepareEndRebalance()` and `dtf.prepareDistributeFees()` now take `{ version }`. Read it with `sdk.index.getVersion` / `useIndexDtfVersion`. +- These five builders now return `IndexDtfCall` (`contract.abi` is `Abi`, `contract.functionName` is `string`, `contract.args` is `readonly unknown[]`); call sites that relied on the narrower inferred type must widen. +- New `getIndexDtfRebalanceNonce` (`sdk.index.getRebalanceNonce`, `ref.getRebalanceNonce`, `useIndexDtfRebalanceNonce`) reads Folio 6.0's `getRebalanceNonce()`. diff --git a/.changeset/index-dtf-version-contract.md b/.changeset/index-dtf-version-contract.md new file mode 100644 index 0000000..1ddc30b --- /dev/null +++ b/.changeset/index-dtf-version-contract.md @@ -0,0 +1,11 @@ +--- +"@reserve-protocol/sdk": minor +"@reserve-protocol/react-sdk": minor +--- + +Index DTF rebalance: explicit version contract and protocol-accurate auction state. + +- `prepareIndexDtfOpenAuctionArgs`, `prepareIndexDtfOpenAuction` and `buildIndexDtfStartRebalanceArgs` now require `version` (`"5.0.0"` | `"6.0.0"`) and reject anything else with `SdkError(INVALID_INPUT)`; there is no silent v5 default. `buildIndexDtfStartRebalance` reads the proxy version when none is passed and applies the same check. +- v6 open-auction math takes `auctionLength` and carries it into the args; `prepareIndexDtfOpenAuction` uses it when no explicit length is passed. +- `getLatestAuction` / `getActiveAuction` resolve one block first and pin every read to it, report `currentRebalanceNonce` and `blockNumber`, and treat an auction as active only while `startTime <= now <= endTime` (inclusive, as Folio checks) and its nonce matches the current rebalance. +- Open-auction inputs reject zero, negative or non-finite prices before the rebalance library runs. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9ef45b..9e2f2cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,9 @@ jobs: - name: Build run: pnpm build + - name: Package Entry Points + run: pnpm --filter @reserve-protocol/sdk check:package + - name: Typecheck run: pnpm typecheck diff --git a/.gitignore b/.gitignore index ff5a0eb..7c76e0d 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ pnpm-debug.log* .DS_Store *.tsbuildinfo + +.claude/ +.scratch/ diff --git a/.oxfmtrc.json b/.oxfmtrc.json index fbdf7ad..c56fa77 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -34,6 +34,8 @@ "**/coverage/**", "**/.pnpm-store/**", "**/.output/**", + "**/.claude/**", + "**/.scratch/**", "**/*.generated.ts", "**/CHANGELOG.md", "scripts/llm-workflow/**", diff --git a/README.md b/README.md index 7e9c9ef..24d03ca 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ pnpm docs:links pnpm release:ci ``` -`pnpm release:ci` runs typecheck, lint/format, live-schema GraphQL codegen freshness, tests, builds (including the docs site and SDK bundle budget), documentation/catalog checks, and package dry-runs for all publishable packages. +`pnpm release:ci` runs checked-in ABI freshness, typecheck, lint/format, live-schema GraphQL codegen freshness, tests, builds (including the docs site and SDK bundle budget), documentation/catalog checks, and package dry-runs for all publishable packages. ## Quick Example diff --git a/apps/docs/src/pages/gotchas.mdx b/apps/docs/src/pages/gotchas.mdx index e07ff3f..24bf06d 100644 --- a/apps/docs/src/pages/gotchas.mdx +++ b/apps/docs/src/pages/gotchas.mdx @@ -35,7 +35,7 @@ The SDK handles protocol encoding. Some Index DTF write builders need a contract version. -Current settings/proposal write builders support `5.0.0` and `6.0.0` targets where implemented. Some issuance, rebalance, and auction helpers are modern/v5-shaped; verify the specific builder before replacing legacy calldata. +Every settings, rebalance, auction and fee-distribution builder requires `version` (`5.0.0` or `6.0.0`) and rejects other releases. `prepareMint` and `prepareRedeem` take it too (their bytes are identical in v5 and v6, but v4 and unknown releases are rejected); verify before replacing legacy v4 calldata. See [Contracts & Versions](/index-dtf/contracts-and-versions) before hand-encoding writes. diff --git a/apps/docs/src/pages/index-dtf/contracts-and-versions.mdx b/apps/docs/src/pages/index-dtf/contracts-and-versions.mdx index a3ee4a2..ceb1350 100644 --- a/apps/docs/src/pages/index-dtf/contracts-and-versions.mdx +++ b/apps/docs/src/pages/index-dtf/contracts-and-versions.mdx @@ -10,9 +10,11 @@ Current SDK settings/proposal write builders support `5.0.0` and `6.0.0` ABI tar Version-specific areas include: +- basket rebalance proposals - allowlist settings - self-fee settings - auction length settings +- mutable and immutable fee-recipient tables ## Why This Matters @@ -25,12 +27,45 @@ startRebalance(address[] newTokens, BasketRange[] newLimits, Prices[] newPrices, SDK/Register ABIs can use newer signatures like: ```solidity +// v5 startRebalance(TokenRebalanceParams[] tokens, RebalanceLimits limits, ...) + +// v6 +startRebalance(uint256 rebalanceNonce, TokenRebalanceParams[] tokens, RebalanceLimits limits, ..., uint256 deadline) ``` -Use SDK builders unless you have a specific reason to hand-encode a call. +Use SDK builders unless you have a specific reason to hand-encode a call. The v6 basket proposal builder reads the current rebalance nonce, encodes the next nonce, and requires an explicit Unix-seconds `deadline` so a stale proposal cannot start a rebalance. It also reads the trade allowlist: with enforcement on, every rebalance token (kept or new) must be allowlisted or the builder throws `INVALID_INPUT`, because `startRebalance` would revert `Folio__TokenNotAllowlisted`. The check runs at build time; governance can still change the allowlist before the proposal executes. The lower-level `buildIndexDtfStartRebalance` args builder does not read the allowlist. + +Raw v6 fee-recipient calls require both the mutable and full immutable tables, sorted by address and totalling 100% +together; the SDK sorts and checks them before encoding. `buildSettingsProposal` reads the current immutable table with +`sdk.index.getImmutableFeeRecipients` and scales the mutable shares into the remainder. + +## Upgrading A 5.0.0 DTF To 6.0.0 + +The migration is a governance proposal that hands the DTF's `FolioProxyAdmin` to the reviewed `UpgradeSpell_6_0_0` +and casts it. `sdk.index.buildUpgradeToV6Proposal({ address, chainId, spell })` reads the DTF's admin governance and +builds the exact sequence: an optimistic governor first registers the 6.0.0 `startRebalance` selector and unregisters +the 5.0.0 one (the signature changed), then `transferOwnership(spell)` on the proxy admin and `cast(folio, proxyAdmin, +selectorRegistry)`; standard governance gets the last two calls with a zero registry. `buildUpgradeToV6Calls` is the +pure form when the addresses are already known (`selectorRegistry` present means optimistic). The spell address is an +input: no spell is deployed on a public chain yet; the SDK tests reproduce the mainnet sandbox's executed proposals +byte for byte. The description defaults to "Upgrade to Folio 6.0.0". + +Submit it as a standard proposal (`prepareSubmitProposal`) even on an optimistic governor: the registry calls are not +allowlisted selectors. The spell checks its preconditions only at execution, after the vote, so confirm them before +proposing: the executing timelock is the DTF's sole `DEFAULT_ADMIN_ROLE` member, the auction length is at least 120 +seconds, no state change is active, the current rebalance's `availableUntil` has passed, and the last auction has +ended. `getVersionDeployment({ version: "6.0.0" })` tells you whether the chain's registry can serve the upgrade. + +## Which Versions A Chain Can Run + +`sdk.index.getLatestVersion({ chainId })` and `sdk.index.getVersionDeployment({ chainId, version })` read the +on-chain `FolioVersionRegistry` (deployer, Folio implementation, deprecation flag). Use them instead of assuming a +release is live on a chain; `getVersionDeployment` returns `null` for a version that was never registered there. +Deploys do not need registration (6.0.0 deploys go to `INDEX_DTF_V6_DEPLOYER_ADDRESS`); upgrading an existing DTF does, +because its proxy admin resolves the implementation through the registry. -Current rebalance/open-auction and issuance helpers are v5-shaped unless the specific function explicitly accepts a version. Do not assume every write helper supports every deployed Folio version. +Open-auction and issuance helpers remain version-sensitive. Do not assume every write helper supports every deployed Folio version. ## Address Normalization diff --git a/apps/docs/src/pages/index-dtf/deployment.mdx b/apps/docs/src/pages/index-dtf/deployment.mdx index cfa4fb2..37d16e8 100644 --- a/apps/docs/src/pages/index-dtf/deployment.mdx +++ b/apps/docs/src/pages/index-dtf/deployment.mdx @@ -12,6 +12,9 @@ The SDK prepares deployment calls and approval plans. Apps own wallet UX, transa | Governed DTF | `prepareDeployGoverned` or `prepareDeployGovernedPlan` | | Vote-lock staking token | `prepareDeployStakingToken` | +The DTF deploy builders take `version` (`"5.0.0"` | `"6.0.0"`) and deploy through that version's per-chain deployer; +Folio 6.0 deploys also take the v6 details. See the [SDK deployment page](/sdk/deployment#folio-60-deployment). + ## Initial Basket The deployment basket uses raw token amounts. @@ -43,11 +46,14 @@ Defaults: ## Logs -After sending the deployment transaction, parse logs with: +After sending the deployment transaction, parse logs with the same target the deploy builder took: ```ts -const address = extractIndexDtfDeployedAddress(receipt.logs); -const stToken = extractIndexDtfDeployedStakingTokenAddress(receipt.logs); +const address = extractIndexDtfDeployedAddress(receipt.logs, { chainId, version, deployer }); +const stToken = extractIndexDtfDeployedStakingTokenAddress(receipt.logs, { chainId }); ``` -Do this after confirmation, not before. +Only events emitted by the expected deployer count (`getIndexDtfDeployerAddress(target)`, or the chain's governance +deployer for staking tokens): a basket token can emit a lookalike `FolioDeployed` during `transferFrom`. A receipt +without the deployer's event throws `RECORD_NOT_FOUND`; one where the deployer names more than one address throws +`INVALID_RESPONSE`. Do this after confirmation, not before. diff --git a/apps/docs/src/pages/index-dtf/rebalance-auctions.mdx b/apps/docs/src/pages/index-dtf/rebalance-auctions.mdx index e42d4db..8fd7f9d 100644 --- a/apps/docs/src/pages/index-dtf/rebalance-auctions.mdx +++ b/apps/docs/src/pages/index-dtf/rebalance-auctions.mdx @@ -40,9 +40,11 @@ const quote = await sdk.index.getBidQuote({ maxSellAmount, }); +const version = await sdk.index.getVersion({ address, chainId }); // "5.0.0" | "6.0.0" const bid = sdk.index.prepareBid({ address, chainId, + version, auctionId, sellToken, buyToken, diff --git a/apps/docs/src/pages/index-dtf/revenue-fees.mdx b/apps/docs/src/pages/index-dtf/revenue-fees.mdx index 65350ca..51f1353 100644 --- a/apps/docs/src/pages/index-dtf/revenue-fees.mdx +++ b/apps/docs/src/pages/index-dtf/revenue-fees.mdx @@ -15,11 +15,25 @@ const [pendingFeeShares, platformFee, revenue] = await Promise.all([ ## Distribute Fees ```ts -const call = sdk.index.prepareDistributeFees({ address, chainId }); +const version = await sdk.index.getVersion({ address, chainId }); +const call = sdk.index.prepareDistributeFees({ address, chainId, version }); ``` Apps decide when to expose this action. +On Folio 6.0 the DTF carries `fees.immutableRecipients` and `fees.selfFee` from the subgraph, and `getRevenue` folds +both into `effectiveDistribution`: `holders.percentage` is the share kept for holders and immutable recipients are +listed next to the mutable ones. The order matches the contract: the DAO fee comes off first, the self fee keeps its +fraction of the remainder for holders, and the mutable and immutable tables (whose portions sum to 100% together) split +what is left. For example DAO 50%, self fee 25% and tables 60/40 give mutable 22.5%, immutable 15%, holders 12.5%. +`financials.selfRevenue` is the self fee already paid (part of `totalRevenue`, not of `externalRevenue`; 0 before 6.0). +A proposal that rewrites the tables still reads the exact on-chain table with `getImmutableFeeRecipients`. + +With both tables empty, `distributeFees` pays the recipients' pool to the DAO, and the platform share includes it. The split uses the DAO's nominal share; when the DAO fee floor binds (a low TVL or mint fee), the DAO takes more on-chain than `effectiveDistribution` shows. + +The DTF query (`GetIndexDTF`) needs the index subgraph 1.11.1 schema on every configured endpoint; a 1.10.2 endpoint +rejects it, which fails every Index DTF read, not only revenue. + ## Supply Gotcha `totalSupply()` includes pending fee shares. Do not use stale ERC20 supply assumptions for share-price math. diff --git a/apps/docs/src/pages/llm-guide.mdx b/apps/docs/src/pages/llm-guide.mdx index d58e2f2..b8ef8d6 100644 --- a/apps/docs/src/pages/llm-guide.mdx +++ b/apps/docs/src/pages/llm-guide.mdx @@ -57,7 +57,9 @@ That address is LCAP from `indexDtfCatalog`. - SDK write helpers return `ContractCall` objects. - Always pass `call.chainId` through to simulation and wallet layers when supported. - Use SDK builders for versioned writes instead of hand-encoding ABI calldata. -- DTF settings builders often need `version: "5.0.0" | "6.0.0"`; read it with `sdk.index.getVersion({ address, chainId })`. +- Every Index DTF Folio write builder (settings, open/close auction, bid, endRebalance, distributeFees) requires `version: "5.0.0" | "6.0.0"`, mint and redeem included; read it with `sdk.index.getVersion({ address, chainId })`. +- Folio 6.0.0 `prepareEndRebalance` also requires `rebalanceNonce`: the nonce of the rebalance being ended (e.g. `getCurrentRebalance().rebalance.nonce` from when it was shown), not a fresh read, so a newer rebalance is never ended by mistake; 5.0.0 encodes `endRebalance()`. +- V6 basket proposals require an explicit Unix-seconds `deadline`; the SDK reads the current rebalance nonce and encodes the next nonce. When the Folio's trade allowlist is enabled, the builder rejects any rebalance token missing from it (`startRebalance` would revert `Folio__TokenNotAllowlisted`). - Raw version-sensitive `prepare*` helpers should receive the target DTF version explicitly. Higher-level proposal builders may fetch the DTF version when omitted. - Proposal action builders prepare vote, queue, execute, and cancel calls. They do not invalidate queries after a transaction. @@ -86,7 +88,7 @@ That address is LCAP from `indexDtfCatalog`. ## ABIs -All contract ABIs are exported from the sdk root (and re-exported by react-sdk): `dtfIndexAbi` (+ `V1/V2/V4`), `folioArtifactAbi` (v6), governance and staking-vault ABIs, `timelockAbi`, `optimisticTimelockAbi`, `selectorRegistryAbi`, `unstakingManagerAbi`, deployer ABIs, `daoFeeRegistryAbi`, and the proposal decode set (`dtfIndexProposalAbi`, `dtfIndexProposalAbiCatalog`, `indexDtfProposalDecoderAbi`). Never copy ABIs into a consumer project. +All contract ABIs are exported from the sdk root (and re-exported by react-sdk): `dtfIndexAbi` (+ `V1/V2/V4`), `folioV6Abi`, `folioDeployerV6Abi`, `folioVersionRegistryAbi`, the compatibility alias `folioArtifactAbi`, governance and staking-vault ABIs, `timelockAbi`, `optimisticTimelockAbi`, `selectorRegistryAbi`, `unstakingManagerAbi`, deployer ABIs, `daoFeeRegistryAbi`, and the proposal decode set (`dtfIndexProposalAbi`, `dtfIndexProposalAbiCatalog`, `indexDtfProposalDecoderAbi`). Never copy ABIs into a consumer project. ## Public Surface Discovery diff --git a/apps/docs/src/pages/react-sdk/index-dtf-hooks.mdx b/apps/docs/src/pages/react-sdk/index-dtf-hooks.mdx index 8e8aada..f8fe7df 100644 --- a/apps/docs/src/pages/react-sdk/index-dtf-hooks.mdx +++ b/apps/docs/src/pages/react-sdk/index-dtf-hooks.mdx @@ -40,37 +40,45 @@ const price = useIndexDtfPrice({ address, chainId }); ## Product And Analytics Reads -| Hook | Core SDK method | -| ----------------------------------- | -------------------------------------- | -| `useIndexDtfStatus` | `sdk.index.getStatus` | -| `useIndexDtfExposure` | `sdk.index.getExposure` | -| `useIndexDtfHolders` | `sdk.index.getHolders` | -| `useIndexDtfAccountBalanceSnapshot` | `sdk.index.getAccountBalanceSnapshot` | -| `useIndexDtfTransactions` | `sdk.index.getTransactions` | -| `useIndexDtfRevenue` | `sdk.index.getRevenue` | -| `useIndexDtfPlatformFee` | `sdk.index.getPlatformFee` | -| `useIndexDtfPendingFeeShares` | `sdk.index.getPendingFeeShares` | -| `useIndexDtfApprovedRevenueTokens` | `sdk.index.getApprovedRevenueTokens` | -| `useIndexDtfIssuanceState` | `sdk.index.getIssuanceState` | -| `useIndexDtfBidsEnabled` | `sdk.index.getBidsEnabled` | -| `useIndexDtfRebalanceControl` | `sdk.index.getRebalanceControl` | -| `useIndexDtfRebalances` | `sdk.index.getRebalances` | -| `useIndexDtfRebalance` | `sdk.index.getRebalance` | -| `useIndexDtfCurrentRebalance` | `sdk.index.getCurrentRebalance` | -| `useIndexDtfRebalanceLiquidity` | `sdk.index.getRebalanceLiquidity` | -| `useIndexDtfRebalanceAuctions` | `sdk.index.getRebalanceAuctions` | -| `useIndexDtfActiveAuction` | `sdk.index.getActiveAuction` | -| `useIndexDtfLatestAuction` | `sdk.index.getLatestAuction` | -| `useIndexDtfBidQuote` | `sdk.index.getBidQuote` | -| `useIndexDtfCompletedRebalance` | `sdk.index.getCompletedRebalance` | -| `useIndexDtfCompletedRebalances` | `sdk.index.getCompletedRebalances` | -| `useIndexDtfVoteLockState` | `sdk.index.getVoteLockState` | -| `useIndexDtfVoteLockVaultState` | `sdk.index.getVoteLockVaultState` | -| `useIndexDtfVoteLockPreviewDeposit` | `sdk.index.readVoteLockPreviewDeposit` | -| `useIndexDtfVoteLockPreviewRedeem` | `sdk.index.readVoteLockPreviewRedeem` | -| `useAccountPortfolio` | `sdk.portfolio.get` | -| `useAccountPortfolioHistory` | `sdk.portfolio.getHistory` | -| `useAccountPortfolioTransactions` | `sdk.portfolio.getTransactions` | +| Hook | Core SDK method | +| ----------------------------------- | ------------------------------------------ | +| `useIndexDtfStatus` | `sdk.index.getStatus` | +| `useIndexDtfExposure` | `sdk.index.getExposure` | +| `useIndexDtfHolders` | `sdk.index.getHolders` | +| `useIndexDtfAccountBalanceSnapshot` | `sdk.index.getAccountBalanceSnapshot` | +| `useIndexDtfTransactions` | `sdk.index.getTransactions` | +| `useIndexDtfRevenue` | `sdk.index.getRevenue` | +| `useIndexDtfPlatformFee` | `sdk.index.getPlatformFee` | +| `useIndexDtfPendingFeeShares` | `sdk.index.getPendingFeeShares` | +| `useIndexDtfApprovedRevenueTokens` | `sdk.index.getApprovedRevenueTokens` | +| `useIndexDtfIssuanceState` | `sdk.index.getIssuanceState` | +| `useIndexDtfBidsEnabled` | `sdk.index.getBidsEnabled` | +| `useIndexDtfRebalanceControl` | `sdk.index.getRebalanceControl` | +| `useIndexDtfRebalances` | `sdk.index.getRebalances` | +| `useIndexDtfRebalance` | `sdk.index.getRebalance` | +| `useIndexDtfCurrentRebalance` | `sdk.index.getCurrentRebalance` | +| `useIndexDtfRebalanceLiquidity` | `sdk.index.getRebalanceLiquidity` | +| `useIndexDtfRebalanceAuctions` | `sdk.index.getRebalanceAuctions` | +| `useIndexDtfMaxAuctionLength` | `sdk.index.getMaxAuctionLength` (v6) | +| `useIndexDtfRebalanceNonce` | `sdk.index.getRebalanceNonce` (v6) | +| `useIndexDtfSelfFee` | `sdk.index.getSelfFee` (v6) | +| `useIndexDtfImmutableFeeRecipients` | `sdk.index.getImmutableFeeRecipients` (v6) | +| `useIndexDtfTradeAllowlist` | `sdk.index.getTradeAllowlist` (v6) | +| `useIndexDtfIsTokenAllowlisted` | `sdk.index.getIsTokenAllowlisted` (v6) | +| `useIndexDtfLatestVersion` | `sdk.index.getLatestVersion` | +| `useIndexDtfVersionDeployment` | `sdk.index.getVersionDeployment` | +| `useIndexDtfActiveAuction` | `sdk.index.getActiveAuction` | +| `useIndexDtfLatestAuction` | `sdk.index.getLatestAuction` | +| `useIndexDtfBidQuote` | `sdk.index.getBidQuote` | +| `useIndexDtfCompletedRebalance` | `sdk.index.getCompletedRebalance` | +| `useIndexDtfCompletedRebalances` | `sdk.index.getCompletedRebalances` | +| `useIndexDtfVoteLockState` | `sdk.index.getVoteLockState` | +| `useIndexDtfVoteLockVaultState` | `sdk.index.getVoteLockVaultState` | +| `useIndexDtfVoteLockPreviewDeposit` | `sdk.index.readVoteLockPreviewDeposit` | +| `useIndexDtfVoteLockPreviewRedeem` | `sdk.index.readVoteLockPreviewRedeem` | +| `useAccountPortfolio` | `sdk.portfolio.get` | +| `useAccountPortfolioHistory` | `sdk.portfolio.getHistory` | +| `useAccountPortfolioTransactions` | `sdk.portfolio.getTransactions` | `useIndexDtfStatus` is synchronous and returns the catalog status directly. Query-backed hooks accept TanStack Query options; `useIndexDtfPerformance` reserves `select` for its raw-cache-preserving composition. Use `usePrefetchIndexDtfPriceHistory` to warm canonical price-history keys. diff --git a/apps/docs/src/pages/sdk/deployment.mdx b/apps/docs/src/pages/sdk/deployment.mdx index d35cc53..8c3c259 100644 --- a/apps/docs/src/pages/sdk/deployment.mdx +++ b/apps/docs/src/pages/sdk/deployment.mdx @@ -9,18 +9,20 @@ import { DEFAULT_INDEX_DTF_DEPLOY_FLAGS, INDEX_DTF_DEPLOYER_ADDRESS, INDEX_DTF_GOVERNANCE_DEPLOYER_ADDRESS, + INDEX_DTF_V6_DEPLOYER_ADDRESS, } from "@reserve-protocol/sdk"; ``` ## Helpers -| Helper | Use | -| -------------------------------------------- | ---------------------------------------------------- | -| `generateIndexDtfDeploymentNonce` | Generate a deployment nonce. | -| `sdk.index.buildDeployFeeRecipients` | Build deploy fee-recipient config. | -| `sdk.index.getDeployApprovalAmount` | Apply an approval buffer to an initial asset amount. | -| `extractIndexDtfDeployedAddress` | Extract DTF address from deployment logs. | -| `extractIndexDtfDeployedStakingTokenAddress` | Extract staking token address from deployment logs. | +| Helper | Use | +| -------------------------------------------- | ----------------------------------------------------------- | +| `generateIndexDtfDeploymentNonce` | Generate a deployment nonce. | +| `sdk.index.buildDeployFeeRecipients` | Build deploy fee-recipient config. | +| `sdk.index.getDeployApprovalAmount` | Apply an approval buffer to an initial asset amount. | +| `extractIndexDtfDeployedAddress` | Extract DTF address from the deployer's logs. | +| `getIndexDtfDeployerAddress` | Resolve the deployer for `{ chainId, version, deployer? }`. | +| `extractIndexDtfDeployedStakingTokenAddress` | Extract staking token from the governance deployer's logs. | ## Builders @@ -34,11 +36,25 @@ import { | `prepareDeployAssetApproval` | Approval for one deploy asset. | | `prepareDeployAssetApprovals` | Approval calls for deploy assets. | +## Versions + +Every DTF deploy builder (`prepareDeploy`, `prepareDeployGoverned` and their plans) takes `version`. `"5.0.0"` deploys +through the per-chain 5.0.0 `FolioDeployer` (`INDEX_DTF_DEPLOYER_ADDRESS`). `"6.0.0"` deploys through the per-chain +6.0.0 `FolioDeployer` (`INDEX_DTF_V6_DEPLOYER_ADDRESS`) and takes a v6 `additionalDetails` shape and, for governed +deploys, one optimistic `governance` table. Pass `deployer` only to target another 6.0.0 deployer (a fork or sandbox). +The v6 constant does not depend on the chain's `FolioVersionRegistry`: `sdk.index.getLatestVersion({ chainId })` tells +you whether the registry has registered 6.0.0 yet. + +Approvals follow the deployed version. Plans approve whichever deployer the plan resolved; +`prepareDeployAssetApproval(s)` take `version` and default the spender to that version's deployer, so a 6.0.0 deploy +never approves the 5.0.0 deployer unless you pass it as `deployer`. + ## Basic Shape ```ts const plan = sdk.index.prepareDeployPlan({ chainId: 8453, + version: "5.0.0", basicDetails: { name: "Example Index DTF", symbol: "eDTF", @@ -69,6 +85,7 @@ Governed deployments include an existing vote-lock token plus owner and trading ```ts const call = sdk.index.prepareDeployGoverned({ chainId, + version: "5.0.0", stToken, basicDetails, additionalDetails, @@ -92,6 +109,44 @@ const call = sdk.index.prepareDeployGoverned({ }); ``` +## Folio 6.0 Deployment + +```ts +const call = sdk.index.prepareDeployGoverned({ + chainId, + version: "6.0.0", // deploys through INDEX_DTF_V6_DEPLOYER_ADDRESS[chainId] + stToken, // an optimistic vote-lock vault (getPastOptimisticVotes) + basicDetails, + additionalDetails: { + maxAuctionLength: 1800n, + feeRecipients, + immutableFeeRecipients: [], + tvlFee: 0n, + mintFee: 0n, + selfFee: 0n, // D18 fraction of non-DAO fees kept for holders, at most 1e18 + mandate: "Example mandate", + }, + flags: DEFAULT_INDEX_DTF_DEPLOY_FLAGS, + governance: { + optimistic: { vetoDelay: 3600, vetoPeriod: 86400, vetoThreshold: 100000000000000000n }, + standard: { + votingDelay: 0, + votingPeriod: 86400, + voteExtension: 600, + proposalThreshold: 10000000000000000n, // D18 fraction of the vault supply: 1% + quorumNumerator: 30000000000000000n, // D18 fraction (the governor's quorum denominator is 1e18): 3% + }, + optimisticSelectors: ["0xc1e54b89"], // startRebalance(uint256,...) on 6.0.0 + optimisticProposers: [], + additionalGuardians: [], + timelockDelay: 86400n, + proposalThrottleCapacity: 5n, + }, +}); +``` + +Ungoverned v6 deploys use the same `version` and `additionalDetails` with `prepareDeploy`. + ## Approval Buffer `getDeployApprovalAmount({ amount })` defaults to an `approvalBufferBps` of `20000`, which is 2x the input amount. diff --git a/apps/docs/src/pages/sdk/governance.mdx b/apps/docs/src/pages/sdk/governance.mdx index 298aa01..d22232c 100644 --- a/apps/docs/src/pages/sdk/governance.mdx +++ b/apps/docs/src/pages/sdk/governance.mdx @@ -29,13 +29,14 @@ Proposal IDs can be extremely long decimal strings. Keep them as strings in rout ## Voting And Account State -| Method | Use | -| -------------------- | -------------------------------------------------- | -| `getDelegates` | Delegate list for a staking/vote-lock token. | -| `getGuardians` | Guardian addresses for Index DTF governance. | -| `getLegacyVoteLocks` | Legacy vote-lock governance entries. | -| `getVoterState` | Account voting state for a vote-lock token. | -| `getProposerState` | Account proposal threshold and voting power state. | +| Method | Use | +| ----------------------- | --------------------------------------------------------------------------------------------- | +| `getDelegates` | Delegate list for a staking/vote-lock token. | +| `getGuardians` | Guardian addresses for Index DTF governance. | +| `getLegacyVoteLocks` | Previous vote-lock vaults of a DTF, including the vault a replaced admin governor voted with. | +| `getVoteLockDependents` | DTFs whose owner or trading governance votes with a vault, with the live role check. | +| `getVoterState` | Account voting state for a vote-lock token. | +| `getProposerState` | Account proposal threshold and voting power state. | ```ts const proposalDetail = await sdk.index.getProposal({ @@ -91,12 +92,14 @@ Vote support values are: The SDK builds proposal payloads from product-shaped inputs. -| Method | Use | -| ----------------------------- | --------------------------------------------------- | -| `buildBasketProposal` | Build a basket/rebalance proposal. | -| `buildBasketSettingsProposal` | Build basket settings changes. | -| `buildDaoSettingsProposal` | Build vote-lock/DAO settings changes. | -| `buildSettingsProposal` | Build DTF, governance, and shared settings changes. | +| Method | Use | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `buildBasketProposal` | Build a basket/rebalance proposal. | +| `buildBasketSettingsProposal` | Build basket settings changes. | +| `buildDaoSettingsProposal` | Build vote-lock/DAO settings changes. | +| `buildSettingsProposal` | Build DTF, governance, and shared settings changes. | +| `buildUpgradeToV6Proposal` | Upgrade a 5.0.0 DTF to 6.0.0 through its admin governance (spell address required). | +| `buildUpgradeToV6Calls` | The same upgrade as bare calls when the addresses are already known; see [Contracts & Versions](/index-dtf/contracts-and-versions). | ```ts const proposal = await sdk.index.buildBasketProposal({ @@ -136,6 +139,26 @@ These are direct package exports, not bound `sdk.index` methods: Versioned DTF builders require `version: "5.0.0" | "6.0.0"`. Read the DTF version first with `sdk.index.getVersion({ address, chainId })`. +### Folio 6.0 settings + +`buildSettingsProposal` accepts three 6.0-only fields; a v5 DTF rejects each by name. + +```ts +const proposal = await sdk.index.buildSettingsProposal({ + address, + chainId, + version: "6.0.0", + selfFee: 5, // percent of non-DAO fees kept for holders + tradeAllowlist: { add: [tokenA], remove: [tokenB], enabled: true }, + revenueDistribution: { platformFee: 0, governanceShare: 0, deployerShare: 100, additionalRecipients: [] }, + // immutableFeeRecipients: read from RPC when omitted; pass the table to pin it explicitly +}); +``` + +On 6.0.0 a revenue change keeps the immutable table exactly as it is on chain and scales the mutable shares into the +remainder, so the two tables still total 100%. A DTF whose immutable table already takes 100% cannot distribute a +mutable share and the builder says so. + ## Optimistic Governance Optimistic governance has its own context and vote surfaces. diff --git a/apps/docs/src/pages/sdk/index-dtf.mdx b/apps/docs/src/pages/sdk/index-dtf.mdx index ebc5238..19da12f 100644 --- a/apps/docs/src/pages/sdk/index-dtf.mdx +++ b/apps/docs/src/pages/sdk/index-dtf.mdx @@ -67,7 +67,7 @@ const [basket, price] = await Promise.all([ | `getIssuanceState` | Wallet balances, allowances, and modern/v5 mint/redeem previews. | | `getRedeemMinAmounts` | Apply slippage bps to expected redeem amounts. | | `prepareBasketApproval` | ERC20 approval for one basket token. | -| `prepareMint` | Modern/v5 `mint(shares, receiver, minSharesOut)` call. | +| `prepareMint` | `mint(shares, receiver, minSharesOut)` call; takes `version`. | | `prepareMintPlan` | Mint plan with optional approvals. | | `prepareRedeem` | Redeem call. | @@ -108,6 +108,30 @@ const plan = sdk.index.prepareMintPlan({ | `getRevenue` | Revenue view. | | `prepareDistributeFees` | Materialize pending fee shares. | +## Folio 6.0 State + +On a 6.0 DTF the indexed model already carries `version`, `rebalance.maxAuctionLength`, `rebalance.tradeAllowlist`, +`fees.immutableRecipients` and `fees.selfFee`. These read the v6 contract directly and fail on a v5 proxy (the immutable-table read probes `folioFeeForSelf` first, so a v5 proxy is an error rather than an empty table); gate them on `getVersion`. + +| Method | Use | +| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `getMaxAuctionLength` | Per-auction length ceiling (`maxAuctionLength()`). | +| `getRebalanceNonce` | Current rebalance nonce; the next `startRebalance` must use nonce + 1. | +| `getSelfFee` | `folioFeeForSelf()` as a D18 `Amount`; `formatted` is a fraction (`"0.05"` = 5%) while the `selfFee` setting takes percent. | +| `getImmutableFeeRecipients` | The full immutable table; pass it back unchanged to `setFeeRecipients`. | +| `getTradeAllowlist` | `{ enabled, tokens }` from `tradeAllowlistEnabled()` / `getTokenAllowlist()`. | +| `getIsTokenAllowlisted` | One token's allowlist membership only; pair it with `getTradeAllowlist().enabled`, since with enforcement off every token is tradable. | + +## Version Registry + +`FolioVersionRegistry` lists the Folio releases each chain can deploy or upgrade to. Addresses live in +`INDEX_DTF_VERSION_REGISTRY_ADDRESS`; pass `registry` to read a different one (a fork, a staging registry). + +| Method | Use | +| ---------------------- | ------------------------------------------------------------------------------------------------ | +| `getLatestVersion` | Newest registered release: `{ versionHash, version, deployer, deprecated }`. | +| `getVersionDeployment` | One release by string (`"6.0.0"`): deployer, implementation, deprecated; `null` if unregistered. | + ## Rebalance Liquidity And Health | Method | Use | diff --git a/apps/docs/src/pages/sdk/rebalances.mdx b/apps/docs/src/pages/sdk/rebalances.mdx index 2ad0bf3..b282ac6 100644 --- a/apps/docs/src/pages/sdk/rebalances.mdx +++ b/apps/docs/src/pages/sdk/rebalances.mdx @@ -20,6 +20,13 @@ const activeAuction = await sdk.index.getActiveAuction({ }); ``` +`getRebalanceNonce` reads Folio 6.0's `getRebalanceNonce()` (the value the next `startRebalance` must exceed by one); +it reverts on a v5 proxy. Both auction readers resolve one block first and pin every read to it. An auction is active only while +`startTime <= now <= endTime` (inclusive, exactly as Folio's `bid` checks) **and** its `rebalanceNonce` equals the +current rebalance nonce read at the same block; a time-valid auction left over from a previous rebalance is reported +with `isActive: false`. The result carries `currentRebalanceNonce` and `blockNumber` so callers can reason about +staleness. + ## Historical State Use historical methods for analytics and completed lifecycle views. @@ -36,12 +43,12 @@ Use historical methods for analytics and completed lifecycle views. | Method | Use | | -------------------------------- | -------------------------------------------------- | -| `prepareOpenAuctionArgs` | Build version-aware open-auction args. | -| `prepareOpenAuction` | Open an auction. | +| `prepareOpenAuctionArgs` | Build v5-compatible open-auction args. | +| `prepareOpenAuction` | Encode a version-aware open-auction call. | | `prepareOpenAuctionUnrestricted` | Open an auction after the community window starts. | | `prepareBid` | Bid on an auction. | | `prepareCloseAuction` | Close an auction. | -| `prepareEndRebalance` | End the active rebalance. | +| `prepareEndRebalance` | End the active rebalance (6.0.0: by nonce). | ```ts const quote = await sdk.index.getBidQuote({ @@ -56,6 +63,7 @@ const quote = await sdk.index.getBidQuote({ const call = sdk.index.prepareBid({ address, chainId, + version, auctionId: 1n, sellToken, buyToken, @@ -64,6 +72,36 @@ const call = sdk.index.prepareBid({ }); ``` +Every auction builder (`prepareOpenAuctionArgs`, `prepareOpenAuction`, `prepareOpenAuctionUnrestricted`, +`prepareBid`, `prepareCloseAuction`, `prepareEndRebalance`) and the revenue builder `prepareDistributeFees` require +`version` (`"5.0.0"` or `"6.0.0"`, read it from `getVersion`) and reject anything else with `SdkError(INVALID_INPUT)`; +there is no silent v5 default and Folio 4.x is not supported by these builders. Where the call shape is identical in +both versions the bytes are too; the ABI attached to the prepared call follows the version. For Folio 6.0 pass the positive `auctionLength` once, to the args builder: it drives +the library's v6 math and is carried into the encoded sixth argument. Use `maxAuctionLength()` from RPC unless the +rebalance's price control allows a shorter custom length. + +```ts +const version = await sdk.index.getVersion({ address, chainId }); // "6.0.0" +const { args } = sdk.index.prepareOpenAuctionArgs({ ...input, version, auctionLength: 1800n }); +const call = sdk.index.prepareOpenAuction({ address, chainId, version, args }); +``` + +Folio 6.0.0 binds `endRebalance` to the rebalance it ends: pass the `rebalanceNonce` of the rebalance the user or +bot is acting on (the one it displayed or launched auctions for), and the Folio reverts `Folio__InvalidRebalanceNonce` +if a newer rebalance has started since. Reading the nonce right before building the call defeats that guard. Folio +5.0.0 keeps the nonce-less `endRebalance()`. On-chain, `startRebalance` and `endRebalance` also accept +`type(uint256).max` to skip the nonce check; the SDK basket builder always sends the exact next nonce. + +```ts +const { rebalance } = await sdk.index.getCurrentRebalance({ address, chainId }); // the rebalance on screen +// ...later, when the user ends it: +const rebalanceNonce = BigInt(rebalance.nonce); +const end = sdk.index.prepareEndRebalance({ address, chainId, version: "6.0.0", rebalanceNonce }); +``` + +Open-auction inputs are validated before the rebalance library runs: a zero, negative or non-finite current price +(or snapshot price when the target basket uses snapshot prices) throws instead of skewing weights. + ## Bidding Mode `bidsEnabled` is a DTF deployment/settings flag, not a per-rebalance flag. diff --git a/docs/index-dtf/contracts-and-versions.md b/docs/index-dtf/contracts-and-versions.md index 303df92..b7a7ceb 100644 --- a/docs/index-dtf/contracts-and-versions.md +++ b/docs/index-dtf/contracts-and-versions.md @@ -45,14 +45,14 @@ Register may label roles differently. Encode the contract role, not the UI label Only include version deltas verified from source, changelogs, or SDK ABIs. Older public docs and local contract source may not describe every deployed SDK target. -| Version | Important Deltas | -| ------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `1.0.0` | Initial release. | -| `2.0.0` | Repeatable auctions, dust limits, minimum mint output. | -| `3.0.0` | Skipped/deprecated path around individual repeatable auctions against target weights. | -| `4.0.0` | Trusted fillers, rebalance targets, auction overhaul, daily fee accounting, `AUCTION_APPROVER` replaced by `REBALANCE_MANAGER`. | -| `5.0.0` | SDK-supported write ABI for current deployed Index DTF settings and rebalance proposal flows. | -| `6.0.0` | SDK-supported write ABI with v6-specific settings names such as `setMaxAuctionLength`; verify function support from ABI before encoding. | +| Version | Important Deltas | +| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `1.0.0` | Initial release. | +| `2.0.0` | Repeatable auctions, dust limits, minimum mint output. | +| `3.0.0` | Skipped/deprecated path around individual repeatable auctions against target weights. | +| `4.0.0` | Trusted fillers, rebalance targets, auction overhaul, daily fee accounting, `AUCTION_APPROVER` replaced by `REBALANCE_MANAGER`. | +| `5.0.0` | SDK-supported write ABI for current deployed Index DTF settings and rebalance proposal flows. | +| `6.0.0` | SDK-supported write ABI with explicit rebalance nonce/deadline, mutable and immutable fee-recipient tables, and settings names such as `setMaxAuctionLength`. | ## SDK Version Handling @@ -60,10 +60,11 @@ The SDK handles version-specific write calls where the affected builder encodes - v5 auction length setter: `setAuctionLength`. - v6 auction length setter: `setMaxAuctionLength`. +- v6 basket proposal: `startRebalance(rebalanceNonce, tokens, limits, auctionLauncherWindow, ttl, deadline)`. Product builders should accept a known DTF version or fetch it when needed. Keep version checks local to the affected write/read handler instead of adding a central operation registry. -Current rebalance/open-auction and issuance helpers are v5-shaped unless the builder explicitly accepts a version. Do not assume every write helper supports every listed version. +The basket proposal builder supports v5 and v6. For v6 it reads the current nonce, encodes the next nonce, and requires an explicit Unix-seconds deadline. Raw v6 fee-recipient calls require both mutable and full immutable tables; the higher-level revenue-distribution proposal reads the immutable table from RPC and scales the mutable shares into the remainder. Every Folio write helper, issuance included, takes `version` and rejects releases other than 5.0.0 and 6.0.0. Source owner: SDK calldata builders are owned by `dtf-sdk/packages/sdk/src/index-dtf/governance/propose/calls.ts`; protocol mechanics are owned by the Index DTF contract repo. diff --git a/docs/known-gotchas.md b/docs/known-gotchas.md index 820e113..20e5f41 100644 --- a/docs/known-gotchas.md +++ b/docs/known-gotchas.md @@ -34,7 +34,8 @@ This file collects facts that are easy to get wrong. Check it before changing SD - `reserve-index-dtf` source may be ahead of live deployed versions. - Current SDK write builders support v5 and v6 ABI targets. Do not assume every deployed DTF uses the latest ABI. - Older docs and comments can mention v3 or v4 signatures. Verify against the ABI/version being encoded. -- `startRebalance`, `openAuction`, and `bid` signatures have changed across versions. +- `startRebalance`, `openAuction`, and `bid` signatures have changed across versions. V6 `startRebalance` requires the expected next nonce, an execution deadline, and at least two rebalance tokens. +- V6 `setFeeRecipients` replaces both tables at once. Callers must preserve the full immutable recipient table; the high-level revenue-distribution proposal reads it from RPC (`getImmutableFeeRecipients`) and passes it back unchanged. ## Subgraph Mismatches diff --git a/docs/plans/index-dtf-v6-completion.md b/docs/plans/index-dtf-v6-completion.md new file mode 100644 index 0000000..5421e1d --- /dev/null +++ b/docs/plans/index-dtf-v6-completion.md @@ -0,0 +1,72 @@ +# Index DTF v6 — SDK completion + +**Status: S1–S6 implemented (2026-09-18), whole-feature review in progress; human review required before publishing. Branch `feat/index-dtf-v6-support`, PR #45. Releases as 0.7.0 through the branch's minor changesets; 1.0.0 is a separate decision once Register re-pins.** + +The rebalance write path (open auction, start rebalance, version guards, protocol-accurate auction state) shipped on this branch. A function-by-function diff of Folio 5.0.0 against 6.0.0 (`index-protocol` `origin/main` `18706fb`) shows what the SDK still lacks. This plan closes those gaps so "v6 support" means the whole 6.0.0 surface, not the rebalance slice. + +## Goal + +Every Folio 6.0.0 external change either has an SDK read, builder or hook with a test, or is named here as out of scope with a reason. No builder chooses an ABI silently: every write takes `version` and rejects what it cannot encode. + +## Gaps (from the protocol diff) + +| Protocol change (v6) | SDK today | Stage | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | ------------------------------------------- | +| `bid`, `closeAuction`, `endRebalance`, `openAuctionUnrestricted`, `distributeFees` unchanged in v6 | builders hardcode the v5 ABI, no `version`, no test pinning v6 equality | S1 | +| `getRebalanceNonce()` added | used privately by the basket proposal builder, not exported | S1 | +| `immutableFeeRecipients`, `folioFeeForSelf` added (subgraph indexes both since v6) | not on `IndexDtf`, no read | S2 | +| trade allowlist reads (`tradeAllowlistEnabled`, `getTokenAllowlist`, `isTokenAllowlisted`) | ABI only | S2 | +| `FolioVersionRegistry` (`getLatestVersion`, `getImplementationForVersion`, `isDeprecated`) | ABI only, no addresses | S2 | +| `FolioAdditionalDetails` gained `maxAuctionLength` (renamed), `immutableFeeRecipients`, `folioFeeForSelf`; `deployGovernedFolio` collapsed to one optimistic `GovParams` | deploy builders encode v5 shapes; a v6 deploy mis-encodes | S3 | +| `setFolioSelfFee`, `setFeeRecipients(recipients, immutable)`, allowlist writes | call builders exist; settings/revenue proposals cannot express them (revenue rejects v6) | S4 | +| 5→6 migration via `UpgradeSpell_6_0_0.cast(folio, proxyAdmin, selectorRegistry)` plus selector registry swap | nothing | S5 | +| Untested v6 writes (self fee, allowlist) and reads (max auction length helper/hook) | shipped without tests | S1, S2, S4 | +| Fork smoke (`test:smoke:index:fork`) never runs in CI | env-gated | S6 (local evidence; CI is a decision below) | + +Not gaps: `RebalanceStarted` / `AuctionOpened` are byte-identical in v6, so history reads and subgraph mappers need nothing; proposal calldata decoding already carries a 6.0.0 ABI entry. + +## Non-goals + +- Publishing. Changesets only; Luis publishes. +- A generic version binder. Each builder branches on `version` explicitly, as `calls.ts` already does. +- `emergencyCloseTrustedFill` (admin emergency path, no product consumer yet) — noted in the backlog. +- CI infrastructure for the fork lane (needs secrets and a runner with Docker) — decision for Luis, not silent work. + +## Stages + +Each stage is one commit on the branch, its own scoped verify, and a progress row. Base for S1: `40b67f3`. + +- **S1 — Version contract on every write.** `prepareIndexDtfBid`, `prepareIndexDtfCloseAuction`, `prepareIndexDtfEndRebalance`, `prepareIndexDtfOpenAuctionUnrestricted`, `prepareIndexDtfDistributeFees` take `version` (asserted, ABI chosen by version). Export `getIndexDtfRebalanceNonce` with namespace, ref, query options, key and hook. Tests: v5 and v6 calldata byte-equal for each builder, `4.0.0` rejected, hook/query-option tests for the new read. Docs: `sdk/rebalances.mdx`, `react-sdk/index-dtf-hooks.mdx`. Register consumes this SDK through `link:`, so its community launch button (`community-launch-auctions-button.tsx`) and any ref call to `prepareEndRebalance`/`prepareDistributeFees` break on the next SDK build; the Register update ships with the closeout (S6), not with a re-pin. +- **S2 — v6 state reads.** `IndexDtf.fees` gains the v6 fields from the subgraph in S7 (below); the SDK does not build RPC fallbacks into the DTF model. RPC reads (exact tables at proposal time): `getIndexDtfImmutableFeeRecipients` (exact table at proposal time), `getIndexDtfSelfFee`, `getIndexDtfTradeAllowlist` (`enabled`, `tokens`), `getIndexDtfIsTokenAllowlisted`, and `getIndexDtfVersionRegistry` (`latest`, `implementationFor(version)`, `isDeprecated(version)`) with `INDEX_DTF_VERSION_REGISTRY_ADDRESS` per chain read from each chain's deployer `versionRegistry()`. Each read: namespace, ref, query options, key, hook, tests; `getIndexDtfMaxAuctionLength` gets the helper and hook tests it lacks. +- **S3 — Deployer v6.** `prepareIndexDtfDeploy` / `prepareIndexDtfDeployGoverned` (+ plans) take `version`. v6 additional details: `maxAuctionLength`, `feeRecipients`, `immutableFeeRecipients`, `tvlFee`, `mintFee`, `selfFee`, `mandate`. v6 governed: one `GovParams` (optimistic + standard params, selectors, proposers, additional guardians, timelock delay, throttle). No public v6 deployer existed at the time, so v6 required an explicit `deployer` address; v5 keeps the per-chain map. Resolved 2026-09-30: the real 6.0.0 deployers are pinned in `INDEX_DTF_V6_DEPLOYER_ADDRESS` and `deployer` is an optional override (see decisions). Tests decode against `folioDeployerV6Abi`. Foundation review (codex) on the API shape before the tests go green. +- **S4 — Settings and revenue proposals on v6.** Schema and params gain `selfFee`, `immutableFeeRecipients` (revenue distribution on v6 passes the current immutable table through unchanged; explicit param overrides the read), `tradeAllowlist: { enabled?, add?, remove? }`. v5 rejects each with an exact error. `hasIndexDtfSettingsCall` updated (`getIndexDtfSettingsVersion` already admits both versions); `immutableFeeRecipients` is params-only, not schema, since a raw table is not form input. Tests: settings builder, decoder round-trip for every new call, the previously untested self-fee and allowlist builders. +- **S5 — Upgrade to 6.0.0 proposal.** `buildIndexDtfUpgradeToV6Proposal({ spell, selectorRegistry?, governance })`: standard governance → `[proxyAdmin.transferOwnership(spell), spell.cast(folio, proxyAdmin, selectorRegistry)]`; optimistic governance → prefixed by `registerSelectors(v6 startRebalance)` and `unregisterSelectors(v5)`. Spell and registry addresses are inputs (none deployed publicly). Tests compare calldata byte-for-byte with the sandbox fixture's recorded proposal (`index-subgraph/.fork/fixture.json`). +- **S7 — Subgraph v6 fields (release dependency).** The full v6 release ships with the index subgraph exposing `immutableFeeRecipients`, `folioFeeForSelf`, `tradeAllowlistEnabled` and `tradeTokenAllowlist` on `DTF` (already in `index-subgraph/schema.graphql`, not yet on the prod endpoint the SDK's codegen tracks). When it is deployed: add the four fields to `dtf.graphql`, map `fees.immutableRecipients`, `fees.selfFee` and `rebalance.tradeAllowlist` in `dtf/mappers.ts`, extend `Fees`/`IndexDtfRebalanceConfig`, make `getEffectiveRevenueDistribution` fold the immutable table and the holders' share, and regenerate (`pnpm graphql:codegen`, `graphql:codegen:check` green). Blocked by: the subgraph deployment, nothing in the SDK. Integrated into `feat/index-dtf-v6-support` on 2026-09-30 and regenerated against subgraph 1.11.1 (`1.11.1-test`, adds nullable `selfRevenue`); publish only when all three prod endpoints serve 1.11.1 (`check:subgraph-endpoints` in `release:ci`). +- **S6 — Fork evidence, docs, review, closeout.** Extend `fork-smoke.test.ts` so the SDK-built v6 deploy, settings and upgrade calldata execute against the fork's v6 deployer and spell; run the sandbox and smoke locally; whole-feature review (Dark, Light, codex); changesets; wiki ingest; handoff with **Engineer review required** (calldata builders, deploy contract, upgrade path). + +## Acceptance evidence + +| Stage | Evidence | +| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| S1 | For each of the five builders, `data` for `version: "5.0.0"` equals `data` for `"6.0.0"`, and `"4.0.0"` throws `INVALID_INPUT`. `getIndexDtfRebalanceNonce` reads `getRebalanceNonce` at the given block; hook wires the key. | +| S2 | A v6 DTF document maps immutable recipients and self fee; a v5 document maps them empty/zero without fallback noise. Registry reads return the live latest version on all three chains (recorded, not mocked). | +| S3 | v6 `deployFolio` and `deployGovernedFolio` calldata decode with `folioDeployerV6Abi` to the exact structs; a v6 deploy on the fork emits `FolioDeployed` for a folio whose `version()` is `6.0.0`. | +| S4 | A v6 settings proposal with self fee, immutable table and allowlist decodes to the exact calls; the same params on v5 throw naming the field. | +| S5 | Upgrade proposal calldata equals the sandbox's executed proposal for both governance kinds. | +| S6 | Fork smoke passes with the new cases; gate green; reviews reconciled; changesets present. | + +## Release assumption + +The full v6 release includes the subgraph update. The SDK therefore keeps `IndexDtf` subgraph-shaped and does not fold RPC reads into the DTF or revenue models; S7 lands the fields when the prod schema exposes them. RPC reads remain for what a write needs exactly (the immutable table a proposal passes back, the self fee, the allowlist). + +## Decisions for Luis (not blocking) + +- **v6 deployer addresses.** Resolved 2026-09-30: published for 1/8453/56 and pinned in `INDEX_DTF_V6_DEPLOYER_ADDRESS`; `deployer` is now optional. +- **Fork lane in CI.** The smoke needs an archive RPC secret and a Docker runner. Proposal: a scheduled workflow with `FORK_RPC_MAINNET` as a repository secret, non-blocking, reporting to the PR. Not done in this plan. +- **1.0.0.** After this plan lands and Register re-pins, the version contract is complete. Cut 1.0.0 then, or stay on 0.x until the API team weighs in. + +## Risks + +- Subgraph codegen: the deployed schema must expose `immutableFeeRecipients` and `folioFeeForSelf`; `pnpm graphql:codegen:check` decides. If not deployed yet, the fields stay RPC-only until it is. +- The optimistic `GovParams` shape belongs to `reserve-governor`; the SDK encodes what the v6 deployer ABI declares and nothing more. +- The upgrade spell on `origin/main` of the protocol was deleted; the reviewed source is pinned by commit in the sandbox. The builder targets the ABI, not the source. diff --git a/docs/plans/register-index-dtf-react-sdk-migration.md b/docs/plans/register-index-dtf-react-sdk-migration.md index 1197eae..74d9ee2 100644 --- a/docs/plans/register-index-dtf-react-sdk-migration.md +++ b/docs/plans/register-index-dtf-react-sdk-migration.md @@ -43,7 +43,7 @@ Implemented in core SDK: - `sdk.index.getIssuanceState`, `prepareMint`, `prepareMintPlan`, `prepareRedeem`, `prepareBasketApproval`, `getRedeemMinAmounts`. - `sdk.index.prepareDistributeFees`. - direct deploy builders: `prepareDeploy`, `prepareDeployGoverned`, `prepareDeployStakingToken`, deploy approval helpers, and deploy plans. -- deploy event extraction helpers: `extractIndexDtfDeployedAddress` and `extractIndexDtfDeployedStakingTokenAddress` are top-level named exports, not `sdk.index` methods. +- deploy event extraction helpers: `extractIndexDtfDeployedAddress` and `extractIndexDtfDeployedStakingTokenAddress` are top-level named exports, not `sdk.index` methods. Both take the deploy target (`{ chainId, version, deployer? }` / `{ chainId }`) and only trust that deployer's logs; Register's zap deploy path, where the zapper picks the deployer server-side, cannot use them as-is and keeps its own candidate-deployer filter. - governance proposal reads/actions/builders through the Index namespace/ref. - rebalance reads/builders: rebalances, auctions, bid quote, bid/open/close/end builders. - vote-lock reads/builders through the Index namespace/ref. diff --git a/docs/wiki/decisions.md b/docs/wiki/decisions.md index d971ea3..33c3e79 100644 --- a/docs/wiki/decisions.md +++ b/docs/wiki/decisions.md @@ -1,6 +1,6 @@ --- title: Decisions -updated: 2026-08-25 +updated: 2026-09-30 type: decision --- @@ -39,3 +39,11 @@ Package publishing runs directly on pushes to `main` or manual dispatches select ## 2026-09-08 — Cross-DTF governance reads live in the SDK, per product The internal governance dashboard needs protocol-wide views (all proposals, top voters, activity, staking totals). Those reads belong in the SDK so Register's explorer can drop its raw GraphQL later, but they stay per product (`sdk.index.*` and `sdk.yield.*` with the same names) rather than a merged namespace: the two subgraphs disagree on attribution (vault vs rToken), lifecycle actors, and state derivation, and the dashboard merges rows itself. Governance→DTF attribution follows owner/trading governance first and falls back to every DTF on the vault only for the vault DAO governance. Arbitrum is out of scope: the SDK has no Arbitrum chain and Index DTFs are not deployed there. + +## 2026-09-18 — Every Folio write takes a version; v6 reads are RPC-only until the subgraph ships + +The SDK never picks a Folio ABI silently: every write builder (auction, settings, fee distribution, deploy) takes `version` and rejects anything but 5.0.0 / 6.0.0, even where the calldata is byte-identical, so a v4 or unknown proxy cannot receive v5 bytes by default. Folio 6.0 state a write needs exactly (self fee, immutable fee recipients, allowlist) is read from RPC at one pinned block; the DTF and revenue models stay subgraph-shaped and gain the v6 fields with the subgraph release rather than through RPC fallbacks; the immutable table is confirmed with a v6 probe and a boundary read so a v5 proxy or a transport failure never reads as an empty table. Fee tables are validated at build time to FolioLib's rules (sorted, non-zero, not the folio, ≤64, total 1e18 across both tables) because a bad table otherwise reverts only after a governance vote. v6 deploys require an explicit deployer address: no public 6.0.0 `FolioDeployer` is registered, and a speculative chain map would encode against nothing. The 5.0.0 → 6.0.0 upgrade builder reproduces the reviewed spell proposal byte for byte and takes the spell address as input for the same reason. + +## 2026-09-30 — v6 deploys target the real 6.0.0 deployer; deploy approvals take a version + +Reserve deployed the production Folio 6.0.0 `FolioDeployer` on mainnet, Base and BSC before registering it in any `FolioVersionRegistry`. The SDK pins those addresses in `INDEX_DTF_V6_DEPLOYER_ADDRESS` and defaults the `"6.0.0"` deploy target to them rather than resolving the deployer from the registry: a deploy is a write, and the registry lagging the deployment must not block it or make it depend on an extra read. `deployer` stays as an override for forks and sandboxes. Standalone deploy approvals now require `version`, extending the "no silent v5 default" rule to approval spenders, so a v6 deploy can never approve the 5.0.0 deployer by default. diff --git a/docs/wiki/domains/react-sdk.md b/docs/wiki/domains/react-sdk.md index b72d743..8ee72d7 100644 --- a/docs/wiki/domains/react-sdk.md +++ b/docs/wiki/domains/react-sdk.md @@ -1,6 +1,6 @@ --- title: React SDK Domain -updated: 2026-09-08 +updated: 2026-09-18 type: domain sources: - packages/react-sdk/src/** @@ -29,3 +29,5 @@ The wrapper externalizes core SDK and peer dependencies and remains small. Keep ## Current pressure Index read coverage is broad. The next large maintenance split is `yield-dtf-hooks.ts`, but only when another Yield feature lands so the split follows real domains rather than speculative structure. + +Folio 6.0 reads (`useIndexDtfMaxAuctionLength`, `useIndexDtfRebalanceNonce`, `useIndexDtfSelfFee`, `useIndexDtfImmutableFeeRecipients`, `useIndexDtfTradeAllowlist`, `useIndexDtfIsTokenAllowlisted`) sit at the default tier except the rebalance nonce, which is a write input and sits at the live tier; version registry hooks (`useIndexDtfLatestVersion`, `useIndexDtfVersionDeployment`) sit at the static tier. All fail on a v5 proxy; gate them on `useIndexDtfVersion`. diff --git a/docs/wiki/domains/sdk.md b/docs/wiki/domains/sdk.md index 80e1fd8..bb44c48 100644 --- a/docs/wiki/domains/sdk.md +++ b/docs/wiki/domains/sdk.md @@ -1,6 +1,6 @@ --- title: Core SDK Domain -updated: 2026-09-08 +updated: 2026-09-30 type: domain sources: - packages/sdk/src/** @@ -28,13 +28,18 @@ sources: - Index DTF proposal IDs are globally unique and do not need DTF-membership checks. - Governance→DTF attribution (`governance/governed-dtfs.ts`) is the reverse index of `getDtfProposalGovernanceIds` over every DTF on a chain, memoized per client and chain for 60s so reads mounting together share one walk: owner, trading, and vault governances, current or legacy, each map to the DTFs that name them. Resolving from the vault's current DTF list broke after vault migrations (old MAG7 vault on Base lists no DTFs). Vault-scoped rows (top voters, totals, stake records) still use the vault's current DTFs. Vaults without an indexed `underlying` are skipped, never invented. - Activity feeds fetch each lifecycle transition ordered by its own timestamp (aliased root fields in one document), so an old proposal executed today still surfaces. Index vault rows skip zero-share records (a withdrawal to another receiver writes a bookkeeping row on the receiver). Yield staking rows come from `Entry` rows of type STAKE/UNSTAKE; `AccountStakeRecord` is also written for stRSR transfers and for the zero address. The Index subgraph records the caller for queue, execute, and governor cancels but not guardian cancels (they run through the timelock), so that row carries the timelock address. Yield lifecycle rows carry only the proposer because that subgraph indexes no lifecycle callers. -- Public call builders require exact calldata and value assertions when changed. -- GraphQL-generated output must match the configured deployed schemas; ordinary CI and `release:ci` rerun codegen and reject drift. +- Standard proposal voting power uses the exact `voteStart` snapshot, including checkpoints written at that timepoint; only current/future snapshots clamp to `clock - 1`. +- Public call builders require exact calldata and value assertions when changed. Folio v6 basket proposals require at least two tokens, read and increment the current rebalance nonce, require an explicit execution deadline, and reject rebalance tokens missing from an enabled trade allowlist; v5 remains the four-argument call. V6 fee-recipient writes must preserve the full immutable table. Every Folio write builder (`prepareIndexDtfBid`, `CloseAuction`, `EndRebalance`, `OpenAuctionUnrestricted`, `DistributeFees`, open auction, start rebalance, settings calls) takes `version` and selects its ABI through `index-dtf/write-version.ts` (`assertIndexDtfWriteVersion` / `getIndexDtfWriteAbi`); calls whose shape is identical across v5/v6 are pinned byte-equal in tests. v6-only reads live in `dtf/folio-v6.ts` (`getIndexDtfMaxAuctionLength`, `getIndexDtfRebalanceNonce`, `getIndexDtfSelfFee`, `getIndexDtfImmutableFeeRecipients`, `getIndexDtfTradeAllowlist`, `getIndexDtfIsTokenAllowlisted`), take no version and fail on a v5 proxy; gate them on `getVersion`. The immutable-table read pins one block (`getBlockNumber({ cacheTime: 0 })`), probes `folioFeeForSelf` first, reads the 64-slot array getter in one unchunked multicall and confirms the boundary index reverts as a contract call before trusting a short table. `version-registry.ts` reads `FolioVersionRegistry` (`INDEX_DTF_VERSION_REGISTRY_ADDRESS`, overridable for forks). Fee tables go through `fee-recipients.ts`: sorted numerically by address, no zero/folio entries, ≤64 combined, exactly 1e18 across mutable + immutable; v6 `setFeeRecipients` and v6 deploys assert this at build time, and a v6 revenue proposal scales the mutable shares into what the immutable table leaves. v6 `prepareIndexDtfEndRebalance` takes `rebalanceNonce` (`endRebalance(uint256)`); v5 keeps `endRebalance()`. Deploy-event extraction takes the deploy target and only trusts logs from that deployer (the chain's governance deployer for staking tokens), rejecting receipts that name more than one address. Deploys are version-discriminated: v5 uses `INDEX_DTF_DEPLOYER_ADDRESS`, v6 `INDEX_DTF_V6_DEPLOYER_ADDRESS` (the real 6.0.0 deployers, independent of registry registration) unless `deployer` overrides it; standalone deploy approvals take `version` so their default spender follows it. `fork-real-v6.test.ts` proves the defaults and runs a complete rebalance (launcher and community auctions, closes, `endRebalance(nonce)`) on the deployed bytecode of all three chains, recomputing prices and bid sizes from contract reads; it also `eth_call`s every v6 ABI function to prove dispatch. `governance/propose/upgrade-v6.ts` builds the 5.0.0 → 6.0.0 spell proposal (selector swap for optimistic governors, `transferOwnership(spell)`, `cast`). `prepareIndexDtfMint`/`Redeem` take `version` too (identical v5/v6 bytes, v4 rejected). `getIndexDtfRevenue` stays subgraph-shaped: the v6 fee fields come from subgraph 1.11.1 (`version`, `maxAuctionLength`, trade allowlist, immutable recipients, `folioFeeForSelf`, `selfRevenue`, all nullable for grafted DTFs) and the effective split takes the DAO fee first, then the self fee for holders, then the two tables. +- GraphQL-generated output must match the configured deployed schemas; ordinary CI and `release:ci` rerun codegen and reject drift. The index schema defaults to `prod`; `INDEX_DTF_SUBGRAPH_SCHEMA` points codegen and its check at a candidate deployment before promotion (the default check stays red until `prod` serves the schema the documents query), and the check restores the checked-in types when it fails. +- Exported ABIs are named exports only: a module with both a named and a default export made the unbundled CJS build export `{ default: abi }`. `check:package` packs the tarball and compares the ESM and CJS entries export by export. +- Fork suites that write (`fork-smoke-v6-mutating`, `fork-real-v6`) refuse port 8545 and the sandbox manifest's RPC through `assertDisposableForkRpcUrl`; `test:smoke:index:fork` stays read-only so it can run against the indexed sandbox. - Account balance snapshots bind through both the namespace and DTF ref. `selectPriceAtMark(points, mark?)` requires timestamped points, never selects a future or non-positive price when a mark is provided, and preserves latest-positive selection when it is omitted. - Yield proposal lists combine indexed vote totals with the latest chain-native timepoint, so they can be eventually consistent near `voteEnd`; proposal detail reads authoritative governor state. +Regression tests use complete decoded transaction payloads and independent economic fixtures. Distinct token targets, owner/trading settings, unequal basket shares, and request-sensitive historical reads expose wiring mistakes that equal inputs and unconditional mocks hide. Keep tests colocated and use tables for meaningful boundaries; the package README owns test authoring guidance. + ## Current pressure Register adoption lags the published SDK. Add new core reads only when a concrete consumer cannot be migrated with the existing namespace. The full/current DTF route fields and rebalance-health boundary are implemented; current pressure is consuming them in Register and filling the consumption-driven Yield gaps in `docs/SDK_AUDIT_2026-07-09.md`. -The package preserves internal modules while retaining one ergonomic root API. A consumer price reader is 15.41 kB minified/5.12 kB gzip and excludes Zod, rebalance-lib, and Decimal; `check:sdk-bundle` protects that boundary. +The package preserves internal modules while retaining one ergonomic root API. Folio v6, FolioDeployer v6, and FolioVersionRegistry ABIs are generated from the pinned protocol checkout; sync verifies artifact source hashes and ABI digests, while release CI verifies the checked-in digests without requiring a sibling repo. The generated Folio ABI is the single source used for v6 reads, writes, and proposal decoding; do not restore duplicate `folio-artifact` or `dtf-index-abi-v6` copies. A consumer price reader is 15.41 kB minified/5.12 kB gzip and excludes Zod, rebalance-lib, and Decimal; `check:sdk-bundle` protects that boundary. diff --git a/docs/wiki/log.md b/docs/wiki/log.md index 01808a1..f9c085a 100644 --- a/docs/wiki/log.md +++ b/docs/wiki/log.md @@ -1,6 +1,6 @@ --- title: Log -updated: 2026-08-25 +updated: 2026-09-30 type: log --- @@ -31,9 +31,16 @@ Append-only chronological record: lessons, corrections, friction. Newest section ## 2026-08-25 - The release workflow's `workflow_run` checkout allowed a successful fork workflow to select code executed with repository write permissions and npm OIDC. Publishing now starts only from `main` pushes or manual `main` dispatches, while the existing Changesets v1 release-PR and trusted-publishing flow remains unchanged. +- The advertised Folio v6 write target was only partial: its generated ABI and basket proposal path still encoded v5 `startRebalance`. Syncing the Folio artifact from `reserve-index-dtf` main exposed the six-argument nonce/deadline call, the two-token minimum, and two-table fee-recipient setter. The SDK now uses rebalance-lib 3.3.1, reads nonce+1 for v6 proposals, requires an explicit deadline, preserves the v5 path, and reuses one generated v6 ABI instead of two drifting copies. Raw v6 fee-recipient calls require the full immutable table; the high-level revenue proposal rejects v6 rather than emitting calldata that can discard it. PR CI also caught deployed Index subgraph schema drift, so the committed generated types were refreshed and the live codegen check rerun green. + +## 2026-09-02 + +- Reproducible ABI sync now pins Folio v6, FolioDeployer v6, and FolioVersionRegistry generation to protocol commit `18706fb455b8e6b91250deba795eb791243f6827`. The protocol-backed check verifies Foundry source hashes and ABI digests; release CI verifies checked-in digests without a sibling repo. A live manifest-driven fork smoke validates distinct v5 control, governed optimistic/legacy v5-to-v6 upgrades with exact proposal evidence, and native v6 against one loopback Anvil state, including real v6 rebalance nonce/deadline encoding. ## 2026-09-08 +- SDK test hardening replaced shape-only assertions with full transaction payloads, independently calculated economic vectors, distinct governance settings, and request-sensitive historical reads. All ten audit mutations now fail in isolated copies: empty v6 basket/auction arrays, discarded mint slippage, reused owner governance, dropped historical block, misdirected Yield approvals, reversed proposal calls, unweighted APY, ignored on-chain state, and empty explorer ABI. A tautological object-key test was removed; duplicate v6 proposal coverage was consolidated into the parameterized basket suite, and opaque auction hex became readable decoded arguments with pinned selectors. Package README documents these patterns without introducing a test framework. +- A test had blessed an actual governance bug: standard proposal voting power queried `voteStart - 1`, although Governor counts at `voteStart`. A request-sensitive checkpoint regression failed before the one-line fix and passed afterward; current/future snapshots retain the `clock - 1` clamp. Independent review checked the local protocol sources. Full gate passed with SDK 350 and React SDK 82 tests; 25 live/fork checks stayed opt-in and were not exercised in this stage. Voting behavior changes require engineer review before release. - Charted the governance dashboard reads with a wayfinder pass (map in reserve-dashboard `.scratch/governance-dashboard/`): both products from day one, RSR panel = current vault state + yield Protocol totals, one merged activity feed, Arbitrum dropped. Live subgraph counts (Base: ~600 governances, ~1000 proposals, <2000 staking positions) sized the pagination: 1000-row pages, 10k ceiling. - Dark/Light review caught two silent-truncation paths (feed default of 1000 per chain with Base already past it; `skip`-based walks past 10k rows) and a misattribution (guardian cancels pinned on the proposer). Fixed with a 10k default plus `states` pre-filter, id-cursor walks that throw `LIMIT_EXCEEDED`, and timelock attribution. Lesson: probe the live subgraph for row counts and null patterns before trusting a schema comment. - Codex senior review (gpt-5.6-sol, xhigh) returned BLOCK on five points. Adopted one: `fetchSubgraphPages` now rejects windows past the 10k ceiling with `INVALID_INPUT`. Declined four with evidence: the 1000-row feed default would drop Base's tail (>1000 proposals live); vote weights and delegated votes must use share-token decimals because Base has six-decimal vlUSDC vaults and BSC an eight-decimal vlDOGE vault; guardian cancels have no indexed canceller (index-subgraph `handlers.ts:370` only sets it on governor cancels), so attributing them to the proposer would be wrong. Three of the four restated the pre-review prompt rather than the code. @@ -44,3 +51,28 @@ Append-only chronological record: lessons, corrections, friction. Newest section - Codex round 2 (post-fixes tree): adopted legacy owner/trading governances in DTF attribution and per-transition ordering for lifecycle activity (queued/executed/canceled each fetched by their own timestamp); declined re-adding the `states` filter it had been told about in the prompt — no consumer, and the type was wider than the subgraph enum. Live smoke after the change: index activity now includes executions of proposals created months earlier. - Post-PR review by Luis found three P2s, all reproduced live: proposals on a migrated-away vault lost DTF attribution (fixed with a DTF-side directory per chain), stRSR transfers showed as stake/unstake pairs (fixed by reading STAKE/UNSTAKE `Entry` rows instead of balance records), and withdrawals to another receiver appeared twice (fixed by dropping zero-share bookkeeping records). Lesson: derived-from lists on the current entity cannot answer historical questions; resolve from the side that keeps history. - PR #44 review (JuampiRombola) plus Luis's "are we fetching all proposals?": feeds now take a creation-time window (`since`/`until`, default last 60 days), sit at the static cache tier in react-sdk, dedupe by `id` after a skip walk, and the DTF directory is memoized per client and chain for 60s so four reads mounting together share one walk per chain. The dashboard reads 60 days for the open tables and 12 months for the historical section; its all-time totals became twelve-month totals, since all-time counts belong to the Governance counters, not to a proposal walk. + +## 2026-09-14 + +- Fast-forwarded the v6 integration branch from `7a67fe3` to main `972bd25` (SDK/React SDK 0.6.0), then reapplied the uncommitted v6 and test-hardening work. Recovery stash `153f1cf` retains the original tracked and untracked state. Both public barrels and package settings merged cleanly; SDK domain notes required combining both sets of invariants. The wiki union merge duplicated ledger rows/frontmatter and displaced log dates, so those were reconciled without dropping either history. No workflow tooling change was needed. +- Added public proposal-read cases combining generated v6 calldata decoding with six/eight-decimal vote weights, quorum, and vote rows. Independent review verified upstream exports and all non-overlapping local files were preserved. Full gate passed (SDK 380, React 85; 25 live/fork checks not run), as did generated ABI verification and live-schema codegen freshness. Existing RPC voter-state formatting still assumes 18 decimals and was recorded in backlog, not changed as part of integration. The v6/snapshot patch remains subject to engineer review; nothing was pushed or published. + +## 2026-09-18 + +- v6 completion S1: the last five version-blind Folio write builders now take `version`; the ABI selector moved out of `governance/propose/calls.ts` into `index-dtf/write-version.ts` after both reviewers flagged three domains importing a governance internal. Annotating the builders with `IndexDtfCall` was required, not cosmetic: a union ABI through `prepareContractCall` blew TS7056 on the namespace and ref objects. The nonce query sits at the live tier because it is a write input. Register consumes the SDK through `link:`, so its community launch button breaks on the next SDK build, not on a re-pin. +- v6 completion S2–S6: reads, deployer, settings/revenue, upgrade proposal and fork evidence. Reviews adopted across stages (Dark+Light per stage, codex on the deploy contract: CONFIRM). Two fork-lane lessons: viem caches `getBlockNumber` for a polling interval, so a read pinned to "the current block" right after a write saw the pre-write state until `cacheTime: 0`; and the 6.0.0 spell enumerates the sender's proposer role, so an EOA admin cannot cast it — the fork case hands the control folio to a timelock first. The restored sandbox state (state-only Anvil restart) serves `latest` reads and writes but not historical blocks, so the legacy fork smoke's two historical cases need a full sandbox run. The prod Index subgraph does not expose the v6 fee fields yet; `IndexDtf.fees` stays v5-shaped. +- Fork lane flake: one `fork-smoke-v6` run reverted the first `setFolioSelfFee` send from the impersonated timelock while a scope run was building in parallel; the same suite passed 5/5 on the two runs right after and on an isolated simulate. Unexplained; the script now pins `--no-file-parallelism`, and a repeat should be traced with `cast run` before the snapshot reverts. +- Codex whole-feature review (xhigh) blocked 0.7.0 on three points, all fixed the same day: the immutable-table boundary read now accepts only a contract revert or empty return (`error.walk` over viem's wrapped causes; a wrapped transport failure had read as end-of-table); `getIndexDtfRevenue` on 6.0.0 reads the immutable table and self fee from RPC and folds them into `effectiveDistribution` (`holders.percentage`); `prepareIndexDtfMint`/`Redeem` take `version` so the S1 invariant holds for every Folio write, and `ref.getIsTokenAllowlisted(token)` is positional. Live smoke reads the version registry on all three chains. The v6 fork smoke's settings case reverted twice, both times while a turbo build ran in parallel with Anvil, and passed 4/4 runs otherwise; a manual probe on the persistent sandbox left two extra blocks (self fee set to 5% then back to 0), so the fixture state block is unchanged but the head is 25834917. +- Luis: the full v6 release includes the subgraph support, so the SDK must not build around its absence. Removed the RPC fold from `getIndexDtfRevenue` (added earlier for a codex blocker); the v6 fee fields now land through the subgraph in plan stage S7, and the codex finding is closed by that release dependency rather than by an RPC workaround. + +## 2026-09-30 + +- Reserve deployed the real Folio 6.0.0 `FolioDeployer` on mainnet, Base and BSC ahead of registering it; the SDK now defaults v6 deploys to those addresses (`INDEX_DTF_V6_DEPLOYER_ADDRESS`) and standalone deploy approvals take `version`. On-chain wiring matches the 5.0.0 deployers per chain (same version registry, DAO fee registry and trusted filler registry); the only new piece is optimistic governor deployer 1.1.0, whose `deployWithExistingStakingVault` signature and system event match what the Folio deployer calls. +- `fork-real-v6.test.ts` proves it on disposable Anvil forks of all three chains: default-deployer ungoverned and governed deploys, v6 reads, startRebalance → openAuction → a bid priced at the auction's end with exact settled amounts, and registry registration by the role-registry owner (`0xe825…3064`, the only DEFAULT_ADMIN of role registry `0xE1eC…41C9` on every chain). +- Fork flake root cause: Anvil estimates gas at one timestamp and may mine the next second. Folio's `sync` writes `lastFolioFeePoke = block.timestamp` unconditionally, a no-op SSTORE in the estimate and a real one when mined, so a tight estimate starves the delegatecall into RebalancingLib and the call reverts with empty data (reproduced: startRebalance reverts under a 590k limit while its success uses 587k). The new suite pins a 12M gas limit. This likely explains the unexplained 2026-09-18 `setFolioSelfFee` revert in `fork-smoke-v6`, which sends without a gas limit; not changed there because the sandbox on 8545 was off-limits for this stage. +- S7 drafted on `feat/index-dtf-v6-subgraph-fields`: `dtf.graphql` selects the 1.11.0 fields, the mapper fills `fees.immutableRecipients`/`selfFee`, `rebalance.maxAuctionLength`/`tradeAllowlist` and `version`, and `getEffectiveRevenueDistribution` takes the whole `fees` object. Types were generated against the fork subgraph (`http://127.0.0.1:18000`), which serves the same schema the prod deploy will; the prod codegen check stays red on this branch until then, which is the merge gate. The full sandbox workflow ran from a clean reset: subgraph parity passed at block 25834903 and the SDK fork smoke passed 13/13 after the fixture parser learned that the sandbox skips the authority proposal when roles are already granted. +- Release panel fixes for 0.7.0. The deployed Folio 6.0.0 (explorer-verified, identical on mainnet, Base and BSC) is `reserve-index-dtf@7d97c80`, six commits past the `18706fb` the ABIs came from; the only ABI drift was `endRebalance(uint256 rebalanceNonce)` (Pashov L-05), so every SDK-built 6.0.0 end-rebalance reverted. ABIs re-synced, v6 `prepareIndexDtfEndRebalance` takes the nonce, and the generated ABIs are now compared item for item with committed Blockscout fixtures. Lesson: the fork suite only proved what it called; it now `eth_call`s every v6 ABI function on the deployed bytecode (an unknown selector reverts with empty data). +- The CJS entry exported `{ default: abi }` for every ABI module that had a named and a default export (rolldown unbundled CJS re-exported the namespace); ESM-only checks missed it for 11 exports. `check:package` packs the tarball and compares ESM vs CJS export by export. Its first version exited from inside `try`, leaving the unpacked package under `node_modules/.cache` where `docs:links` scanned it. +- Deploy-event extraction now takes the deploy target and only trusts the deployer's logs. Register's own deploy buttons parse logs themselves and still need the same filter. +- S7 integrated and regenerated against subgraph 1.11.1 (`1.11.1-test` on Goldsky): nullable `tradeTokenAllowlist`/legacy governance lists, `selfRevenue` mapped into `financials`. `INDEX_DTF_SUBGRAPH_SCHEMA` switches the codegen source; the default `prod` check stays red until promotion, and 0.7.0 must not publish before `prod` serves 1.11.1. +- The indexed-sandbox smoke is read-only now (v6 reads pinned to the fixture's `stateBlock`, because the stack lane appends rebalances after recording); its writing cases need a disposable fork. The real-v6 suite runs a complete rebalance per chain; anvil reuses a block timestamp when the wall clock has not advanced, so every timestamp-sensitive step sets `evm_setNextBlockTimestamp` explicitly (a close right after an end-time bid otherwise lands on `endTime` and emits `AuctionClosed`). diff --git a/docs/wiki/progress.md b/docs/wiki/progress.md index 2f1039e..888cbc9 100644 --- a/docs/wiki/progress.md +++ b/docs/wiki/progress.md @@ -1,6 +1,6 @@ --- title: Progress -updated: 2026-09-08 +updated: 2026-09-30 type: ledger --- @@ -8,20 +8,41 @@ type: ledger Stage ledger. One row per stage; keep entries short. Verifier = exact fresh commands that ran green. Lenses = one line each in the Review column. -| Stage | Status | Verifier | Review | Next | -| ----------------------------------------------------------------------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| Cross-DTF governance reads for the governance dashboard (sdk.index, sdk.yield, react-sdk) | human-review-required (base 7a67fe3) | full gate green (sdk 339, react-sdk 85 tests); wiki-lint; live smoke of all 10 reads; dashboard tsc on linked react-sdk | 4 lenses, two passes: Dark+Light (feed decimals, flat delegate votes, states dropped, 10k boundary) fixed; codex round 1: 1 adopted, 4 declined with evidence; round 2 in log | PR to main; release minor | -| Harden package publishing trigger | human-review-required (base 466324e) | full gate: forced build + bundle + types + lint/format + 397 tests (17 skipped) + 93 links + catalog; actionlint 1.7.12 green | correctness+security+complexity: independent review; fixed manual-dispatch ref guard; Changesets v1 release-PR/OIDC flow preserved | engineer review; merge to main | -| vote-lock self-appreciating vault support (0.5.1) | human-review-required (base 9231139) | scoped verify green: format/lint/typecheck/test both packages (vote-lock.test 2, query-keys 12) + bundle + docs:links + wiki-lint | Dark + Light; blocker fixed (redeem builder missing from index-dtf/index.ts + index.ts barrels); multicall-order assertion added; patch changeset per Luis (0.5.1, though API additions are minor-shaped) | engineer review; release 0.5.1; Register uses local link until published | -| 0.5.0 hardening release closeout | done (base f1ee8f5) | Node 24 `release:ci`: types + lint/format + live codegen + 394 tests (+17 live skipped) + build/bundle + 93 docs + catalog + 3 LICENSE-bearing tarballs; forced closeout gate + wiki-lint green | correctness+security+product+complexity: PR #27 reconciled; zero values, status validation, timestamp selection, namespace/ref/hook coverage, schema drift, publish gate, docs, and linked Register RED→GREEN verified | engineer review; commit/push; Changesets release to 0.5.0 | -| governance tie semantics + yield list state + rebalance hardening tests | done (base 588954e) | full gate on Node 24: forced builds + sdk-bundle + typecheck + lint + format + forced tests (sdk 287 passed/17 live skipped, react-sdk 72) + docs links + catalog checks; wiki-lint green | correctness+security+product+complexity: Dark+Light subagent pair; adopted boundary/mixed-flavor vectors and pinned zero-supply message; PENDING-expired labeling and detail QUORUM_NOT_REACHED split verified against Register reference, sent to backlog | Luis review (user-visible governance badge change); release patch | -| Current DTF aggregate, rebalance health, and bundle packaging | done (base 4bcda6a) | `turbo build --force` + SDK bundle gate + typecheck + lint + format + forced tests (340 passed, 17 live skipped) + 93-doc links + catalog checks | correctness+security+product+complexity: self-review, no blockers; external reviewer skipped because delegation was not authorized for closeout | release SDK/React SDK; replace Register local links with released versions | -| SDK audit and Register gap closure | done (base 4bcda6a) | forced builds + types + lint/format + 331 tests (+17 live skipped) + 93-doc links/routes + catalog; release:ci pack dry-runs; workflow 24 tests | correctness+security+product+complexity: independent review; fixed dirty-tree codegen, public-route coverage, docs routing, fresh-build gate | release SDK/React SDK and bump Register | -| workflow adoption | done (base 4bcda6a) | build + typecheck + lint (2 baseline warnings) + format + 321 tests + docs + catalog checks; workflow 24 tests | correctness+complexity: independent review; fixed build/codegen/risk routing, rule preservation, formatter ownership, attribute precedence | audit SDK/React SDK and Register gaps | +| Stage | Status | Verifier | Review | Next | +| ----------------------------------------------------------------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| 0.7.0 fixes: B-1/2/4/5, S7 on 1.11.1, D-1, D-3 | human-review-required (base 00877b4) | `scope.mjs --gate` green, docs:build; `fork-real-v6` 27/27 ×4 (1/8453/56); sandbox smoke 10/10 read-only, 3/3 mutating on a disposable fork | correctness+security: Dark (9 P3, 6 adopted), codex (P2+P3 adopted; P1 fee floor documented, backlog); product+complexity: Light (endpoint guard adopted) | Next: engineer review; publish after prod = 1.11.1 | +| v6 real deployer defaults + real-address fork evidence | human-review-required (base 6b0db30) | `scope.mjs --base 6b0db30` green: SDK 452, React 94; `test:smoke:index:fork-real-v6` 15/15 on forks of 1/8453/56 (5 runs after the gas fix) | correctness+security: Dark + codex CONFIRM (gas root cause corrected); address pin, registered path adopted; product+complexity: Light, changesets de-duplicated | Next: engineer review; Register v6 launch waits on the subgraph | +| v6 completion S2: v6 reads + version registry | human-review-required (base 2328e19) | `scope.mjs --base 2328e19` gate-equivalent green: SDK 446, React 94; fork smoke v6 5/5 | correctness: Dark+Light adopted; security: reads only; product: flat ref, static tier pinned; complexity: `folio-v6.ts` | Next: subgraph v6 fee fields when prod schema ships | +| v6 completion S3: Folio 6.0 deployer | human-review-required (base 2328e19) | same gate; fork: SDK-built v6 deploy, `version()` 6.0.0 | correctness: codex CONFIRM + Dark+Light adopted; security: explicit v6 `deployer`, tables asserted; product: v5/v6 unions; complexity: split `deploy/index.ts` later | Next: superseded 2026-09-30, real deployers pinned | +| v6 completion S4: settings + revenue on 6.0.0 | human-review-required (base 2328e19) | same gate; fork: self fee, two tables, allowlist executed from the timelock and read back | correctness: Dark+Light adopted; security: FolioLib checks at build time; product: helpers exported; complexity: one type source | Next: engineer review of scaling math | +| v6 completion S5: 5.0.0 → 6.0.0 upgrade proposal | human-review-required (base 2328e19) | unit: sandbox proposals byte-equal; fork: v5 control folio upgraded from a timelock admin; scope gate-equivalent green | correctness: Dark+Light adopted (zero registry = standard, injected `dtf`, spell error ABI); security: standard-route note; product: named param types; complexity: selectors via `write-version` | Next: engineer review; spell address once deployed | +| v6 completion S6: fork evidence + closeout | human-review-required (base 2328e19) | final `scope.mjs` gate-equivalent green: SDK 451, React 94; `fork-smoke-v6` 5/5 ×4 (2 reverts seen only under concurrent build load); Register typecheck, 69 unit, 3 launch smoke | whole-feature codex: 3 blockers raised and fixed (boundary read accepts only reverts, revenue view folds v6 tables, mint/redeem take `version`); per-stage Dark+Light above | Next: engineer review, publish 0.7.0, Register re-pin | +| v6 completion S7: Folio 6.0 fields from the subgraph | integrated into 0.7.0 fixes | regenerated vs 1.11.1-test (row above) | row above | Next: publish after prod = 1.11.1 | +| v6 completion S1: version on every write ([plan](../plans/index-dtf-v6-completion.md)) | human-review-required (base 40b67f3) | `scope.mjs --base 40b67f3` gate-equivalent green: format, lint, SDK 420, bundle, React 87, docs links, wiki-lint | correctness: Dark+Light, 14 adopted; security: v5/v6 bytes pinned, 4.0.0 → `INVALID_INPUT`; product: ref shapes `{ version }`; complexity: `write-version.ts` | Next: S2 reads; Register community button needs `version` | +| Integrate latest main with local DTF v6 support | human-review-required (base 7a67fe3; main 972bd25) | `node scripts/llm-workflow/scope.mjs --gate` green: SDK 380/React 85; 25 opt-in skipped; `pnpm abi:index-v6:check-generated` + `pnpm graphql:codegen:check` green | correctness: independent overlap + preservation review; security: snapshot/calldata unchanged; product: mixed v6 + 6/8-decimal regression; complexity: no new runtime code | engineer review of v6/snapshot changes; commit/push/release | +| Cross-DTF governance reads for the governance dashboard (sdk.index, sdk.yield, react-sdk) | human-review-required (base 7a67fe3) | full gate green (sdk 339, react-sdk 85 tests); wiki-lint; live smoke of all 10 reads; dashboard tsc on linked react-sdk | 4 lenses, two passes: Dark+Light (feed decimals, flat delegate votes, states dropped, 10k boundary) fixed; codex round 1: 1 adopted, 4 declined with evidence; round 2 in log | PR to main; release minor | +| Harden SDK regression tests and correct proposal snapshot reads | human-review-required (base 7a67fe3) | `node scripts/llm-workflow/scope.mjs --gate`: forced builds/tests + bundle/types/lint/format/docs/catalog green; SDK 350 passed/25 opt-in skipped, React 82; 10/10 audit mutants rejected; snapshot red→green | correctness: independent protocol/snapshot/payload review; security: destinations/slippage/blocks; product: v5/v6 preserved; complexity: duplicate/tautology removed, table-driven fixtures | engineer review of voting snapshot patch; release changeset | +| Integrate PR38, generated v6 ABIs, and fork smoke | human-review-required (base 7a67fe3) | `release:ci` + forced full gate green; follow-up SDK 330 passed/25 skipped + bundle/lint/types/docs; ABI checks at `18706fb`; live fork 8/8 incl. execution readback | correctness+security+product+complexity: independent review; ABI provenance, upgrade paths, Governor events/state, v5 direct and v6 optimistic rebalance/auction tx/event/state linkage green; no Important findings | engineer review; commit/push; publish SDK and bump Register | +| Sync Folio v6 ABI and basket proposal encoding with reserve-index-dtf main | human-review-required (base 3dbb611) | `turbo build --force` + SDK bundle + typecheck + lint/format + forced tests (SDK 321 passed/17 skipped; React 82) + live GraphQL codegen + 93 docs links + catalog checks; ABI exact-match 166 items; wiki-lint green | correctness+security+product: Codex Sol medium; fixed v6 immutable-recipient omission and protocol two-token minimum; nonce/deadline/version calldata verified | engineer review; merge PR; high-level v6 revenue remains explicitly unsupported | +| Environment-driven Index DTF fork smoke harness | human-review-required (base 7a67fe3) | full gate: forced builds + bundle + types + lint/format + SDK 320 passed/1 expected fail/24 opt-in skipped + React SDK 82 + 93 links + catalog | correctness+security+product+complexity: self; manifest fail-closed/loopback boundary, v5 compatibility, v6 auction ordering, docs, and cache reuse green | superseded by integrated stage above | +| Harden package publishing trigger | human-review-required (base 466324e) | full gate: forced build + bundle + types + lint/format + 397 tests (17 skipped) + 93 links + catalog; actionlint 1.7.12 green | correctness+security+complexity: independent review; fixed manual-dispatch ref guard; Changesets v1 release-PR/OIDC flow preserved | engineer review; merge to main | +| vote-lock self-appreciating vault support (0.5.1) | human-review-required (base 9231139) | scoped verify green: format/lint/typecheck/test both packages (vote-lock.test 2, query-keys 12) + bundle + docs:links + wiki-lint | Dark + Light; blocker fixed (redeem builder missing from index-dtf/index.ts + index.ts barrels); multicall-order assertion added; patch changeset per Luis (0.5.1, though API additions are minor-shaped) | engineer review; release 0.5.1; Register uses local link until published | +| 0.5.0 hardening release closeout | done (base f1ee8f5) | Node 24 `release:ci`: types + lint/format + live codegen + 394 tests (+17 live skipped) + build/bundle + 93 docs + catalog + 3 LICENSE-bearing tarballs; forced closeout gate + wiki-lint green | correctness+security+product+complexity: PR #27 reconciled; zero values, status validation, timestamp selection, namespace/ref/hook coverage, schema drift, publish gate, docs, and linked Register RED→GREEN verified | engineer review; commit/push; Changesets release to 0.5.0 | +| governance tie semantics + yield list state + rebalance hardening tests | done (base 588954e) | full gate on Node 24: forced builds + sdk-bundle + typecheck + lint + format + forced tests (sdk 287 passed/17 live skipped, react-sdk 72) + docs links + catalog checks; wiki-lint green | correctness+security+product+complexity: Dark+Light subagent pair; adopted boundary/mixed-flavor vectors and pinned zero-supply message; PENDING-expired labeling and detail QUORUM_NOT_REACHED split verified against Register reference, sent to backlog | Luis review (user-visible governance badge change); release patch | +| Current DTF aggregate, rebalance health, and bundle packaging | done (base 4bcda6a) | `turbo build --force` + SDK bundle gate + typecheck + lint + format + forced tests (340 passed, 17 live skipped) + 93-doc links + catalog checks | correctness+security+product+complexity: self-review, no blockers; external reviewer skipped because delegation was not authorized for closeout | release SDK/React SDK; replace Register local links with released versions | +| SDK audit and Register gap closure | done (base 4bcda6a) | forced builds + types + lint/format + 331 tests (+17 live skipped) + 93-doc links/routes + catalog; release:ci pack dry-runs; workflow 24 tests | correctness+security+product+complexity: independent review; fixed dirty-tree codegen, public-route coverage, docs routing, fresh-build gate | release SDK/React SDK and bump Register | +| workflow adoption | done (base 4bcda6a) | build + typecheck + lint (2 baseline warnings) + format + 321 tests + docs + catalog checks; workflow 24 tests | correctness+complexity: independent review; fixed build/codegen/risk routing, rule preservation, formatter ownership, attribute precedence | audit SDK/React SDK and Register gaps | ## Backlog +- `getIndexDtfRevenue().effectiveDistribution` uses the DAO's nominal share; when the DAO fee floor binds (low TVL or mint fee), `FolioLib.computeFeeShares`/`computeMintFees` give the DAO more. A floor-aware split needs separate TVL and mint splits from the fee rates (codex, 2026-09-30). +- Register's stack lane does not run `test:smoke:index:fork-mutating`; the v6 settings, deploy and upgrade checks on the sandbox fixture need a disposable-fork step there. + +- A governed v6 deploy needs an optimistic vote-lock vault (`getPastOptimisticVotes`); `prepareIndexDtfDeployStakingToken` only builds a legacy v5 vault, which the v6 deployer rejects with a revert. No SDK builder wraps the optimistic governor deployer's `deployWithNewStakingVault` yet. +- `getOptimisticGovernance().guardians` (and the subgraph's `timelock.guardians`) list every timelock CANCELLER_ROLE member, which includes the governor itself. A settings diff built from a guardians list that omits the governor would revoke its cancel role. + +- Index RPC voter-state reads in `governance/voting.ts` still format voting power with the default 18 decimals, unlike proposal subgraph reads that now use vote-lock share decimals. This predates the main/v6 integration; raw amounts are unaffected. Add share-decimal handling and regressions in a separate fix. - Yield detail (`getYieldDtfProposal`) trusts on-chain `governor.state()`, which reports failed-quorum proposals as DEFEATED while the list derivation distinguishes QUORUM_NOT_REACHED. Decide whether detail should layer the display distinction. - `getYieldDtfProposalState(proposal, currentTimepoint)` relies on the caller passing the governor-native unit (seconds vs blocks); consider an explicit `isTimepointBased` param like `getYieldDtfProposalVotePower` if external adoption grows. diff --git a/docs/wiki/project.md b/docs/wiki/project.md index 717862b..023b52d 100644 --- a/docs/wiki/project.md +++ b/docs/wiki/project.md @@ -1,6 +1,6 @@ --- title: Project -updated: 2026-07-22 +updated: 2026-09-30 type: context --- @@ -15,8 +15,8 @@ Start with `docs/README.md`, then `docs/sdk/architecture.md` and `docs/sdk/api-s ## Stack Specifics - pnpm/Turbo monorepo on Node 24; TypeScript 6, viem, Zod, GraphQL Codegen, Vitest, React 18/19, TanStack Query 5, tsdown, and Vocs. -- Normal closeout commands are owned by `llm-workflow.config.json`. `pnpm release:ci` is the publication check: it adds live-schema GraphQL freshness and package tarball dry-runs to the compile, test, docs, bundle, and catalog gates. -- Generated GraphQL documents are committed. Run `pnpm graphql:codegen` after GraphQL document or codegen changes; ordinary CI and the publication gate reject stale generated output against the configured deployed schemas. +- Normal closeout commands are owned by `llm-workflow.config.json`. `pnpm release:ci` is the publication check: it adds checked-in ABI freshness, live-schema GraphQL freshness, and package tarball dry-runs to the compile, test, docs, bundle, and catalog gates. +- Generated GraphQL documents are committed. Run `pnpm graphql:codegen` after GraphQL document or codegen changes; ordinary CI and the publication gate reject stale generated output against the configured deployed schemas. Before a subgraph release reaches `prod`, generate and check against the candidate with `INDEX_DTF_SUBGRAPH_SCHEMA=<.../dtf-index-base//gn>`; never publish an SDK whose documents `prod` cannot serve. - Releases use Changesets. Never edit package versions or changelogs by hand. ## Safety Rules diff --git a/llm-workflow.config.json b/llm-workflow.config.json index ba62bc2..304439a 100644 --- a/llm-workflow.config.json +++ b/llm-workflow.config.json @@ -3,6 +3,7 @@ "gate": [ "pnpm exec turbo run build --force", "pnpm --filter @reserve-protocol/docs check:sdk-bundle", + "pnpm --filter @reserve-protocol/sdk check:package", "pnpm typecheck", "pnpm lint", "pnpm format:check", @@ -34,7 +35,12 @@ }, { "name": "sdk-bundle", - "globs": ["packages/sdk/src/**", "packages/sdk/package.json", "packages/sdk/tsdown.config.ts"], + "globs": [ + "packages/sdk/src/**", + "packages/sdk/package.json", + "packages/sdk/tsdown.config.ts", + "packages/sdk/scripts/check-package-entries.mjs" + ], "commands": ["pnpm bundle:check"] }, { diff --git a/package.json b/package.json index e9129e2..84178f4 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,11 @@ "url": "https://github.com/reserve-protocol/dtf-interface" }, "scripts": { + "abi:index-v6:check": "node packages/sdk/scripts/sync-index-dtf-v6-abis.mjs --check", + "abi:index-v6:check-generated": "node packages/sdk/scripts/sync-index-dtf-v6-abis.mjs --check-generated", + "abi:index-v6:sync": "node packages/sdk/scripts/sync-index-dtf-v6-abis.mjs", "build": "turbo run build", - "bundle:check": "pnpm --filter @reserve-protocol/sdk build && pnpm --filter @reserve-protocol/docs check:sdk-bundle", + "bundle:check": "pnpm --filter @reserve-protocol/sdk build && pnpm --filter @reserve-protocol/docs check:sdk-bundle && pnpm --filter @reserve-protocol/sdk check:package", "clean": "turbo run clean", "dev": "turbo run dev", "docs:build": "pnpm --filter @reserve-protocol/docs build", @@ -26,7 +29,7 @@ "changeset": "changeset", "version": "changeset version", "release": "changeset publish", - "release:ci": "pnpm typecheck && pnpm lint && pnpm format:check && pnpm graphql:codegen:check && pnpm test && pnpm build && pnpm --filter @reserve-protocol/docs check:sdk-bundle && pnpm docs:links && pnpm --filter @reserve-protocol/dtf-catalog check:restricted && pnpm --filter @reserve-protocol/dtf-catalog check:tokenlists && pnpm --filter @reserve-protocol/dtf-catalog pack --dry-run && pnpm --filter @reserve-protocol/sdk pack --dry-run && pnpm --filter @reserve-protocol/react-sdk pack --dry-run", + "release:ci": "pnpm abi:index-v6:check-generated && pnpm typecheck && pnpm lint && pnpm format:check && pnpm graphql:codegen:check && pnpm test && pnpm build && pnpm --filter @reserve-protocol/docs check:sdk-bundle && pnpm --filter @reserve-protocol/sdk check:package && pnpm --filter @reserve-protocol/sdk check:subgraph-endpoints && pnpm docs:links && pnpm --filter @reserve-protocol/dtf-catalog check:restricted && pnpm --filter @reserve-protocol/dtf-catalog check:tokenlists && pnpm --filter @reserve-protocol/dtf-catalog pack --dry-run && pnpm --filter @reserve-protocol/sdk pack --dry-run && pnpm --filter @reserve-protocol/react-sdk pack --dry-run", "test": "turbo run test", "typecheck": "turbo run typecheck" }, diff --git a/packages/dtf-catalog/src/index.ts b/packages/dtf-catalog/src/index.ts index e0b7f4c..305c396 100644 --- a/packages/dtf-catalog/src/index.ts +++ b/packages/dtf-catalog/src/index.ts @@ -21,7 +21,8 @@ export const yieldDtfs: Partial> = { +// Also exported by name: a bundler's CommonJS interop for a re-exported default can hand out the module namespace. +export const dtfCatalog: Record<1 | 56 | 8453, Record> = { 1: { ...yieldDtfs[1], ...indexDtfs[1], @@ -37,4 +38,4 @@ const dtfs: Record<1 | 56 | 8453, Record>>( + params: MethodParams> | undefined, + options?: DtfQueryOptions>, TData>, +) { + const sdk = useDtfSdk(); + return useQuery(indexDtfMaxAuctionLengthQueryOptions(sdk, params, options)); +} + +export function useIndexDtfRebalanceNonce>>( + params: MethodParams> | undefined, + options?: DtfQueryOptions>, TData>, +) { + const sdk = useDtfSdk(); + return useQuery(indexDtfRebalanceNonceQueryOptions(sdk, params, options)); +} + +export function useIndexDtfSelfFee>>( + params: MethodParams> | undefined, + options?: DtfQueryOptions>, TData>, +) { + const sdk = useDtfSdk(); + return useQuery(indexDtfSelfFeeQueryOptions(sdk, params, options)); +} + +export function useIndexDtfImmutableFeeRecipients>>( + params: MethodParams> | undefined, + options?: DtfQueryOptions>, TData>, +) { + const sdk = useDtfSdk(); + return useQuery(indexDtfImmutableFeeRecipientsQueryOptions(sdk, params, options)); +} + +export function useIndexDtfTradeAllowlist>>( + params: MethodParams> | undefined, + options?: DtfQueryOptions>, TData>, +) { + const sdk = useDtfSdk(); + return useQuery(indexDtfTradeAllowlistQueryOptions(sdk, params, options)); +} + +export function useIndexDtfIsTokenAllowlisted>>( + params: MethodParams> | undefined, + options?: DtfQueryOptions>, TData>, +) { + const sdk = useDtfSdk(); + return useQuery(indexDtfIsTokenAllowlistedQueryOptions(sdk, params, options)); +} + +export function useIndexDtfLatestVersion>>( + params: MethodParams> | undefined, + options?: DtfQueryOptions>, TData>, +) { + const sdk = useDtfSdk(); + return useQuery(indexDtfLatestVersionQueryOptions(sdk, params, options)); +} + +export function useIndexDtfVersionDeployment>>( + params: MethodParams> | undefined, + options?: DtfQueryOptions>, TData>, +) { + const sdk = useDtfSdk(); + return useQuery(indexDtfVersionDeploymentQueryOptions(sdk, params, options)); +} + export function useIndexDtfRebalanceControl>>( params: MethodParams> | undefined, options?: DtfQueryOptions>, TData>, diff --git a/packages/react-sdk/src/index-dtf-query-options.ts b/packages/react-sdk/src/index-dtf-query-options.ts index bae89ec..c6288c7 100644 --- a/packages/react-sdk/src/index-dtf-query-options.ts +++ b/packages/react-sdk/src/index-dtf-query-options.ts @@ -147,6 +147,115 @@ export function indexDtfBidsEnabledQueryOptions>>( + sdk: DtfSdk, + params: MethodParams> | undefined, + options?: DtfQueryOptions>, TData>, +) { + return createDtfQueryOptions( + dtfQueryKeys.index.maxAuctionLength(params), + () => sdk.index.getMaxAuctionLength(requireParams(params, "indexDtfMaxAuctionLengthQueryOptions")), + params !== undefined, + options, + ); +} + +export function indexDtfRebalanceNonceQueryOptions>>( + sdk: DtfSdk, + params: MethodParams> | undefined, + options?: DtfQueryOptions>, TData>, +) { + return createDtfQueryOptions( + dtfQueryKeys.index.rebalanceNonce(params), + () => sdk.index.getRebalanceNonce(requireParams(params, "indexDtfRebalanceNonceQueryOptions")), + params !== undefined, + options, + LIVE_STALE_TIME, + ); +} + +export function indexDtfSelfFeeQueryOptions>>( + sdk: DtfSdk, + params: MethodParams> | undefined, + options?: DtfQueryOptions>, TData>, +) { + return createDtfQueryOptions( + dtfQueryKeys.index.selfFee(params), + () => sdk.index.getSelfFee(requireParams(params, "indexDtfSelfFeeQueryOptions")), + params !== undefined, + options, + ); +} + +export function indexDtfImmutableFeeRecipientsQueryOptions< + TData = MethodResult>, +>( + sdk: DtfSdk, + params: MethodParams> | undefined, + options?: DtfQueryOptions>, TData>, +) { + return createDtfQueryOptions( + dtfQueryKeys.index.immutableFeeRecipients(params), + () => sdk.index.getImmutableFeeRecipients(requireParams(params, "indexDtfImmutableFeeRecipientsQueryOptions")), + params !== undefined, + options, + ); +} + +export function indexDtfTradeAllowlistQueryOptions>>( + sdk: DtfSdk, + params: MethodParams> | undefined, + options?: DtfQueryOptions>, TData>, +) { + return createDtfQueryOptions( + dtfQueryKeys.index.tradeAllowlist(params), + () => sdk.index.getTradeAllowlist(requireParams(params, "indexDtfTradeAllowlistQueryOptions")), + params !== undefined, + options, + ); +} + +export function indexDtfIsTokenAllowlistedQueryOptions>>( + sdk: DtfSdk, + params: MethodParams> | undefined, + options?: DtfQueryOptions>, TData>, +) { + return createDtfQueryOptions( + dtfQueryKeys.index.isTokenAllowlisted(params), + () => sdk.index.getIsTokenAllowlisted(requireParams(params, "indexDtfIsTokenAllowlistedQueryOptions")), + params !== undefined, + options, + ); +} + +export function indexDtfLatestVersionQueryOptions>>( + sdk: DtfSdk, + params: MethodParams> | undefined, + options?: DtfQueryOptions>, TData>, +) { + return createDtfQueryOptions( + dtfQueryKeys.index.latestVersion(params), + () => sdk.index.getLatestVersion(requireParams(params, "indexDtfLatestVersionQueryOptions")), + params !== undefined, + options, + STATIC_STALE_TIME, + ); +} + +export function indexDtfVersionDeploymentQueryOptions>>( + sdk: DtfSdk, + params: MethodParams> | undefined, + options?: DtfQueryOptions>, TData>, +) { + return createDtfQueryOptions( + dtfQueryKeys.index.versionDeployment(params), + () => sdk.index.getVersionDeployment(requireParams(params, "indexDtfVersionDeploymentQueryOptions")), + params !== undefined, + options, + STATIC_STALE_TIME, + ); +} + export function indexDtfRebalanceControlQueryOptions>>( sdk: DtfSdk, params: MethodParams> | undefined, diff --git a/packages/react-sdk/src/index-dtf/index-dtf-data.ts b/packages/react-sdk/src/index-dtf/index-dtf-data.ts index fde4670..35a99fb 100644 --- a/packages/react-sdk/src/index-dtf/index-dtf-data.ts +++ b/packages/react-sdk/src/index-dtf/index-dtf-data.ts @@ -71,6 +71,7 @@ export type IndexDtfData = Omit & { readonly protocolRevenue: number; readonly governanceRevenue: number; readonly externalRevenue: number; + readonly selfRevenue: number; }; export function mapIndexDtfData(dtf: IndexDtfFull): IndexDtfData { @@ -128,6 +129,7 @@ export function mapIndexDtfData(dtf: IndexDtfFull): IndexDtfData { protocolRevenue: dtf.financials.protocolRevenue, governanceRevenue: dtf.financials.governanceRevenue, externalRevenue: dtf.financials.externalRevenue, + selfRevenue: dtf.financials.selfRevenue, }; } diff --git a/packages/react-sdk/src/index-dtf/use-index-dtf-vote-lock-migration.ts b/packages/react-sdk/src/index-dtf/use-index-dtf-vote-lock-migration.ts new file mode 100644 index 0000000..e97bef1 --- /dev/null +++ b/packages/react-sdk/src/index-dtf/use-index-dtf-vote-lock-migration.ts @@ -0,0 +1,57 @@ +import type { + DtfParams, + DtfSdk, + GetIndexDtfVoteLockDependentsParams, + IndexDtfVoteLockDependent, +} from "@reserve-protocol/sdk"; +import type { Address } from "viem"; + +import { useQuery } from "@tanstack/react-query"; + +import { useDtfSdk } from "@/provider"; +import { createDtfQueryOptions, requireParams, type DtfQueryOptions } from "@/query"; +import { dtfQueryKeys } from "@/query-keys"; + +export function indexDtfLegacyVoteLocksQueryOptions( + sdk: DtfSdk, + params: DtfParams | undefined, + options?: DtfQueryOptions, +) { + return createDtfQueryOptions( + dtfQueryKeys.index.governance.legacyVoteLocks(params), + () => sdk.index.getLegacyVoteLocks(requireParams(params, "indexDtfLegacyVoteLocksQueryOptions")), + params !== undefined, + options, + ); +} + +export function useIndexDtfLegacyVoteLocks( + params: DtfParams | undefined, + options?: DtfQueryOptions, +) { + const sdk = useDtfSdk(); + + return useQuery(indexDtfLegacyVoteLocksQueryOptions(sdk, params, options)); +} + +export function indexDtfVoteLockDependentsQueryOptions( + sdk: DtfSdk, + params: GetIndexDtfVoteLockDependentsParams | undefined, + options?: DtfQueryOptions, +) { + return createDtfQueryOptions( + dtfQueryKeys.index.governance.voteLockDependents(params), + () => sdk.index.getVoteLockDependents(requireParams(params, "indexDtfVoteLockDependentsQueryOptions")), + params !== undefined, + options, + ); +} + +export function useIndexDtfVoteLockDependents( + params: GetIndexDtfVoteLockDependentsParams | undefined, + options?: DtfQueryOptions, +) { + const sdk = useDtfSdk(); + + return useQuery(indexDtfVoteLockDependentsQueryOptions(sdk, params, options)); +} diff --git a/packages/react-sdk/src/index.ts b/packages/react-sdk/src/index.ts index 72709e6..5c385d8 100644 --- a/packages/react-sdk/src/index.ts +++ b/packages/react-sdk/src/index.ts @@ -48,6 +48,12 @@ export { export type { DtfQueryOptions, DtfQueryOptionsResult, IndexDtfPastOptimisticVotesQueryParams } from "@/query-options"; export { useIndexDtfDelegates } from "@/index-dtf/use-index-dtf-delegates"; export { useIndexDtfGuardians } from "@/index-dtf/use-index-dtf-guardians"; +export { + indexDtfLegacyVoteLocksQueryOptions, + indexDtfVoteLockDependentsQueryOptions, + useIndexDtfLegacyVoteLocks, + useIndexDtfVoteLockDependents, +} from "@/index-dtf/use-index-dtf-vote-lock-migration"; export { useIndexDtfOptimisticGovernance } from "@/index-dtf/use-index-dtf-optimistic-governance"; export { useIndexDtfOptimisticProposalContext } from "@/index-dtf/use-index-dtf-optimistic-proposal-context"; export { useIndexDtfOptimisticProposalVoterState } from "@/index-dtf/use-index-dtf-optimistic-proposal-voter-state"; @@ -97,6 +103,14 @@ export { indexDtfApprovedRevenueTokensQueryOptions, indexDtfBidQuoteQueryOptions, indexDtfBidsEnabledQueryOptions, + indexDtfMaxAuctionLengthQueryOptions, + indexDtfRebalanceNonceQueryOptions, + indexDtfSelfFeeQueryOptions, + indexDtfImmutableFeeRecipientsQueryOptions, + indexDtfTradeAllowlistQueryOptions, + indexDtfIsTokenAllowlistedQueryOptions, + indexDtfLatestVersionQueryOptions, + indexDtfVersionDeploymentQueryOptions, indexDtfCompletedRebalanceQueryOptions, indexDtfCompletedRebalancesQueryOptions, indexDtfCurrentRebalanceQueryOptions, @@ -190,6 +204,14 @@ export { useIndexDtfApprovedRevenueTokens, useIndexDtfBidQuote, useIndexDtfBidsEnabled, + useIndexDtfMaxAuctionLength, + useIndexDtfRebalanceNonce, + useIndexDtfSelfFee, + useIndexDtfImmutableFeeRecipients, + useIndexDtfTradeAllowlist, + useIndexDtfIsTokenAllowlisted, + useIndexDtfLatestVersion, + useIndexDtfVersionDeployment, useIndexDtfCompletedRebalance, useIndexDtfCompletedRebalances, useAccountPortfolio, diff --git a/packages/react-sdk/src/query-keys.test.ts b/packages/react-sdk/src/query-keys.test.ts index e31a982..cc6cf9e 100644 --- a/packages/react-sdk/src/query-keys.test.ts +++ b/packages/react-sdk/src/query-keys.test.ts @@ -397,6 +397,7 @@ function createDtfKeyFixture() { roles: { admin: { legacy: ["0x0000000000000000000000000000000000000004"], + legacyGovernances: [], }, rebalance: { legacyAuctionApprovers: ["0x0000000000000000000000000000000000000005"], diff --git a/packages/react-sdk/src/query-keys.ts b/packages/react-sdk/src/query-keys.ts index 6ef7a0f..6245725 100644 --- a/packages/react-sdk/src/query-keys.ts +++ b/packages/react-sdk/src/query-keys.ts @@ -10,6 +10,7 @@ import type { DecodeIndexDtfProposalParams, GetIndexDtfDelegatesParams, GetIndexDtfGuardiansParams, + GetIndexDtfVoteLockDependentsParams, GetIndexDtfPriceHistoryParams, GetIndexDtfPriceParams, GetIndexDtfVersionParams, @@ -213,6 +214,22 @@ export const dtfQueryKeys = { [...dtfQueryKeys.index.all(), "issuance-state", keyParams(params)] as const, bidsEnabled: (params?: IndexMethodParams<"getBidsEnabled">) => [...dtfQueryKeys.index.all(), "bids-enabled", keyParams(params)] as const, + maxAuctionLength: (params?: IndexMethodParams<"getMaxAuctionLength">) => + [...dtfQueryKeys.index.all(), "max-auction-length", keyParams(params)] as const, + rebalanceNonce: (params?: IndexMethodParams<"getRebalanceNonce">) => + [...dtfQueryKeys.index.all(), "rebalance-nonce", keyParams(params)] as const, + selfFee: (params?: IndexMethodParams<"getSelfFee">) => + [...dtfQueryKeys.index.all(), "self-fee", keyParams(params)] as const, + immutableFeeRecipients: (params?: IndexMethodParams<"getImmutableFeeRecipients">) => + [...dtfQueryKeys.index.all(), "immutable-fee-recipients", keyParams(params)] as const, + tradeAllowlist: (params?: IndexMethodParams<"getTradeAllowlist">) => + [...dtfQueryKeys.index.all(), "trade-allowlist", keyParams(params)] as const, + isTokenAllowlisted: (params?: IndexMethodParams<"getIsTokenAllowlisted">) => + [...dtfQueryKeys.index.all(), "is-token-allowlisted", keyParams(params)] as const, + latestVersion: (params?: IndexMethodParams<"getLatestVersion">) => + [...dtfQueryKeys.index.all(), "latest-version", keyParams(params)] as const, + versionDeployment: (params?: IndexMethodParams<"getVersionDeployment">) => + [...dtfQueryKeys.index.all(), "version-deployment", keyParams(params)] as const, rebalanceControl: (params?: IndexMethodParams<"getRebalanceControl">) => [...dtfQueryKeys.index.all(), "rebalance-control", keyParams(params)] as const, rebalances: (params?: IndexMethodParams<"getRebalances">) => @@ -303,6 +320,10 @@ export const dtfQueryKeys = { [...dtfQueryKeys.index.governance.all(), "delegates", keyParams(params)] as const, guardians: (params?: GetIndexDtfGuardiansParams) => [...dtfQueryKeys.index.governance.all(), "guardians", keyParams(indexDtfGuardiansKeyParams(params))] as const, + legacyVoteLocks: (params?: DtfParams) => + [...dtfQueryKeys.index.governance.all(), "legacy-vote-locks", keyParams(params)] as const, + voteLockDependents: (params?: GetIndexDtfVoteLockDependentsParams) => + [...dtfQueryKeys.index.governance.all(), "vote-lock-dependents", keyParams(params)] as const, voterState: (params?: GetIndexDtfVoterStateParams) => [...dtfQueryKeys.index.governance.all(), "voter-state", keyParams(params)] as const, proposerState: (params?: GetIndexDtfProposerStateParams) => diff --git a/packages/react-sdk/src/query-options.test.ts b/packages/react-sdk/src/query-options.test.ts index 4c22cda..a56de12 100644 --- a/packages/react-sdk/src/query-options.test.ts +++ b/packages/react-sdk/src/query-options.test.ts @@ -20,7 +20,15 @@ import { indexDtfPendingFeeSharesQueryOptions, indexDtfPlatformFeeQueryOptions, indexDtfRebalanceAuctionsQueryOptions, + indexDtfMaxAuctionLengthQueryOptions, indexDtfRebalanceControlQueryOptions, + indexDtfRebalanceNonceQueryOptions, + indexDtfSelfFeeQueryOptions, + indexDtfImmutableFeeRecipientsQueryOptions, + indexDtfTradeAllowlistQueryOptions, + indexDtfIsTokenAllowlistedQueryOptions, + indexDtfLatestVersionQueryOptions, + indexDtfVersionDeploymentQueryOptions, indexDtfRebalanceLiquidityQueryOptions, indexDtfRebalanceQueryOptions, indexDtfRevenueQueryOptions, @@ -224,6 +232,70 @@ const extraIndexQueryOptions: readonly QueryOptionCase[] = [ params: { address: DTF, chainId: 1 }, result: true, }, + { + name: "max auction length", + method: "getMaxAuctionLength", + build: indexDtfMaxAuctionLengthQueryOptions, + key: dtfQueryKeys.index.maxAuctionLength, + params: { address: DTF, chainId: 1 }, + result: 1800n, + }, + { + name: "rebalance nonce", + method: "getRebalanceNonce", + build: indexDtfRebalanceNonceQueryOptions, + key: dtfQueryKeys.index.rebalanceNonce, + params: { address: DTF, chainId: 1 }, + result: 12n, + }, + { + name: "self fee", + method: "getSelfFee", + build: indexDtfSelfFeeQueryOptions, + key: dtfQueryKeys.index.selfFee, + params: { address: DTF, chainId: 1 }, + result: { raw: 0n, formatted: "0" }, + }, + { + name: "immutable fee recipients", + method: "getImmutableFeeRecipients", + build: indexDtfImmutableFeeRecipientsQueryOptions, + key: dtfQueryKeys.index.immutableFeeRecipients, + params: { address: DTF, chainId: 1 }, + result: [{ recipient: TARGET, portion: 1n }], + }, + { + name: "trade allowlist", + method: "getTradeAllowlist", + build: indexDtfTradeAllowlistQueryOptions, + key: dtfQueryKeys.index.tradeAllowlist, + params: { address: DTF, chainId: 1 }, + result: { enabled: false, tokens: [] }, + }, + { + name: "is token allowlisted", + method: "getIsTokenAllowlisted", + build: indexDtfIsTokenAllowlistedQueryOptions, + key: dtfQueryKeys.index.isTokenAllowlisted, + params: { address: DTF, chainId: 1, token: TARGET }, + result: true, + }, + { + name: "latest version", + method: "getLatestVersion", + build: indexDtfLatestVersionQueryOptions, + key: dtfQueryKeys.index.latestVersion, + params: { chainId: 1 }, + result: { versionHash: "0x01", version: "5.0.0", deployer: TARGET, deprecated: false }, + }, + { + name: "version deployment", + method: "getVersionDeployment", + build: indexDtfVersionDeploymentQueryOptions, + key: dtfQueryKeys.index.versionDeployment, + params: { chainId: 1, version: "6.0.0" }, + result: null, + }, { name: "rebalance control", method: "getRebalanceControl", @@ -473,10 +545,19 @@ describe("staleTime defaults", () => { getTotalSupply: vi.fn(), getVersion: vi.fn(), getProposal: vi.fn(), + getLatestVersion: vi.fn(), + getVersionDeployment: vi.fn(), }, } as unknown as DtfSdk; const dtfParams = { chainId: 1, address: DTF } as const; + it("keeps version registry reads at the static tier", () => { + expect(indexDtfLatestVersionQueryOptions(sdk, { chainId: 1 }).staleTime).toBe(STATIC_STALE_TIME); + expect(indexDtfVersionDeploymentQueryOptions(sdk, { chainId: 1, version: "6.0.0" }).staleTime).toBe( + STATIC_STALE_TIME, + ); + }); + it("applies the live class to price queries", () => { expect(indexDtfPriceQueryOptions(sdk, dtfParams).staleTime).toBe(LIVE_STALE_TIME); expect(indexDtfTotalSupplyQueryOptions(sdk, dtfParams).staleTime).toBe(LIVE_STALE_TIME); diff --git a/packages/sdk/README.md b/packages/sdk/README.md index aab50db..e21cb90 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -50,6 +50,18 @@ const sdk = createDtfSdk({ const dtfs = await sdk.index.list({ chainId: 1 }); ``` +## Tests + +Run `pnpm --filter @reserve-protocol/sdk test` from the repository root. For one domain, use +`pnpm --filter @reserve-protocol/sdk exec vitest run src/index-dtf/rebalance`. +Tests are colocated with their domain and typechecked with source; live and fork checks remain opt-in. + +- Transaction tests assert the destination, chain, value, and complete decoded arguments. Pin fixed selectors for version-dependent signatures. +- Use independently calculated economic fixtures with unequal weights, different governance settings, nonzero slippage, and mixed decimals. Do not calculate expected values by calling the operation under test. +- Mock external boundaries and make results depend on the request when address, block, or timepoint matters. Use real pure math and encoding dependencies. +- Parameterize actual behavior boundaries; consolidate duplicate scenarios and remove tautologies. Keep failure output focused on relevant request fields instead of entire ABIs. +- For a regression fix, first demonstrate a failing behavior test. When strengthening an existing test, reintroduce the fault in an isolated copy and confirm it fails. Run the ordinary suite on unmodified source as the control. + ## GraphQL Codegen The SDK imports typed GraphQL documents from generated source files. After changing `.graphql` files or `codegen.yml`, regenerate them before running the SDK, build, or typecheck: @@ -60,6 +72,33 @@ pnpm graphql:codegen Generated GraphQL files are checked in so consumers do not need to run codegen after installing the package. +## Index DTF v6 ABI Sync + +The checked-in Folio v6, FolioDeployer v6, and FolioVersionRegistry ABIs are generated from +`reserve-index-dtf` commit `18706fb455b8e6b91250deba795eb791243f6827`. The default protocol checkout is the sibling +`../index-protocol` directory. Verify freshness without modifying files: + +```sh +pnpm abi:index-v6:check +``` + +This protocol-backed check verifies the checkout commit, every source hash recorded in each Foundry artifact, and the +pinned ABI SHA-256 before comparing generated output. Publication CI uses the sibling-free checked-in check: + +```sh +pnpm abi:index-v6:check-generated +``` + +To use an explicit checkout and regenerate after reviewing a protocol update: + +```sh +pnpm abi:index-v6:sync -- --protocol-root /absolute/path/to/reserve-index-dtf +pnpm abi:index-v6:check -- --protocol-root /absolute/path/to/reserve-index-dtf +``` + +The protocol-backed commands reject any protocol `HEAD` other than the pinned commit. Normal unit tests and release CI +consume the checked-in modules and do not depend on a sibling repository. + ## Playground Run a live Index DTF fetch and print real data: @@ -76,6 +115,250 @@ pnpm playground:index 0x4da9a0f397db1397902070f93a4d6ddbc0e0e6e8 8453 This is intentionally not part of the normal test suite or CI path. +## Deterministic Fork Smoke + +The opt-in Index DTF fork smoke verifies four scenarios prepared by an external local-fork runner: + +- a v5 control Folio that remains on v5; +- v5 Folios upgraded to v6 through optimistic and legacy governance; +- a Folio created directly on v6. + +`test:smoke:index:fork` runs two read-only files against the sandbox's indexed Anvil: `fork-smoke.test.ts` (the fixture +runner owns fork startup, deployments, upgrade transactions, and standard governance deployment) and +`fork-smoke-v6.test.ts` (v6 registry and state reads). Neither sends transactions, snapshots, reverts or warps time, so +they are safe while a fork Graph Node indexes the chain. The smoke independently matches each declared upgrade +proposal to its on-chain `ProposalCreated`/`ProposalExecuted` logs and executed Governor state. Run it after those +transitions are mined: + +```sh +RUN_INDEX_DTF_FORK_SMOKE=1 \ +INDEX_DTF_FORK_MANIFEST=/absolute/path/to/fixture.json \ +pnpm --filter @reserve-protocol/sdk test:smoke:index:fork +``` + +The writing v6 cases (SDK-built settings from the admin timelock, a v6 deploy against the sandbox deployer, the +5.0.0 → 6.0.0 upgrade after a 400-day warp, each under `evm_snapshot`/`evm_revert`) live in +`fork-smoke-v6-mutating.test.ts` and have their own script. They need a disposable Anvil forked from the sandbox and +refuse port 8545 and the manifest's own RPC: rewinding a chain under its Graph Node corrupts the index. + +```sh +anvil --fork-url http://127.0.0.1:8545 --port 8549 +INDEX_DTF_FORK_MANIFEST=/absolute/path/to/fixture.json \ +INDEX_DTF_FORK_DISPOSABLE_RPC_URL=http://127.0.0.1:8549 \ +pnpm --filter @reserve-protocol/sdk test:smoke:index:fork-mutating +``` + +When `INDEX_DTF_FORK_MANIFEST` is unset, the harness reads `fixture.json` from `SANDBOX_STATE_DIR`. The manifest is the +single contract between the fixture runner and SDK. The following shows the complete schema; addresses, block numbers, +proposal IDs, and calldata are abbreviated and must come from the fixture runner: + +```json +{ + "schemaVersion": 1, + "rpcUrl": "http://127.0.0.1:8545", + "chainId": 1, + "forkBlock": "25834000", + "stateBlock": "25835000", + "stateTimestamp": "1788300000", + "writePaths": { "deadline": "1788303600", "auctionLength": 300 }, + "protocol": { + "v5Deployer": "0x...", + "v6Deployer": "0x...", + "v6Implementation": "0x...", + "versionRegistry": "0x...", + "upgradeSpell": "0x...", + "upgradeSpellSourceCommit": "33c315690a71c826b5bcd01b69110f478ccd865d", + "startRebalanceSelectors": { "v5": "0x207c8eed", "v6": "0xc1e54b89" } + }, + "scenarios": { + "v5Control": { + "folio": "0x...", + "proxyAdmin": "0x...", + "governance": "0x0000000000000000000000000000000000000000", + "governanceKind": "standard", + "creationBlock": "25834900", + "expectedVersion": "5.0.0", + "execution": { + "actor": "0x...", + "roleAuthority": "direct-admin", + "tokens": ["0x...", "0x..."], + "rebalanceNonce": 1, + "rebalanceTxHash": "0x...", + "rebalanceBlock": "25834980", + "auctionId": 0, + "auctionTxHash": "0x...", + "auctionBlock": "25834990", + "auctionLength": 300 + } + }, + "v5OptimisticUpgrade": { + "folio": "0x...", + "proxyAdmin": "0x...", + "governance": "0x...", + "governanceKind": "optimistic", + "governanceAddresses": { + "governor": "0x...", + "timelock": "0x...", + "stakingVault": "0x...", + "selectorRegistry": "0x..." + }, + "creationBlock": "25834910", + "upgradeBlock": "25834950", + "proposalCallCount": 4, + "expectedVersion": "6.0.0", + "proposal": { + "proposalId": "...", + "entityId": "...", + "kind": "standard", + "optimistic": false, + "state": "executed", + "proposalBlock": "25834940", + "executeBlock": "25834950", + "targets": ["", "", "", ""], + "values": [0, 0, 0, 0], + "calldatas": ["", "", "", ""], + "selectors": ["0x39535e96", "0x3bb9e672", "0xf2fde38b", "0x2aa6b211"] + } + }, + "v5LegacyUpgrade": { + "folio": "0x...", + "proxyAdmin": "0x...", + "governance": "0x...", + "governanceKind": "standard", + "governanceAddresses": { + "governor": "0x...", + "timelock": "0x...", + "stakingVault": "0x...", + "selectorRegistry": "0x0000000000000000000000000000000000000000" + }, + "creationBlock": "25834920", + "upgradeBlock": "25834960", + "proposalCallCount": 2, + "expectedVersion": "6.0.0", + "proposal": { + "proposalId": "...", + "entityId": "...", + "kind": "standard", + "optimistic": false, + "state": "executed", + "proposalBlock": "25834941", + "executeBlock": "25834960", + "targets": ["", ""], + "values": [0, 0], + "calldatas": ["", ""], + "selectors": ["0xf2fde38b", "0x2aa6b211"] + } + }, + "v6Native": { + "folio": "0x...", + "proxyAdmin": "0x...", + "governance": "0x...", + "governanceKind": "optimistic", + "governanceAddresses": { + "governor": "0x...", + "timelock": "0x...", + "stakingVault": "0x...", + "selectorRegistry": "0x..." + }, + "creationBlock": "25834970", + "expectedVersion": "6.0.0", + "execution": { + "actor": "0x...", + "roleAuthority": "optimistic-governance", + "governanceMechanism": "optimistic", + "authority": { + "kind": "standard", + "optimistic": false, + "state": "executed", + "description": "...", + "proposalId": "...", + "proposalTxHash": "0x...", + "proposalBlock": "25834980", + "executeTxHash": "0x...", + "executeBlock": "25834985" + }, + "rebalanceProposal": { + "kind": "optimistic", + "optimistic": true, + "state": "executed", + "description": "...", + "proposalId": "...", + "proposalTxHash": "0x...", + "proposalBlock": "25834990", + "executeTxHash": "0x...", + "executeBlock": "25834995", + "deadline": "1788303600" + }, + "tokens": ["0x...", "0x..."], + "rebalanceNonce": 1, + "deadline": "1788303600", + "rebalanceTxHash": "", + "rebalanceBlock": "25834995", + "auctionId": 0, + "auctionTxHash": "0x...", + "auctionBlock": "25834999", + "auctionLength": 300 + } + } + } +} +``` + +`address` is accepted as a documented alias for each scenario's `folio`. `INDEX_DTF_FORK_RPC_URL` may override the +manifest RPC URL, but both forms are restricted to loopback HTTP. `chainId` is the forked source chain (`1`, `8453`, +or `56`), even when the local RPC reports a development chain ID. No production Folio address is built into the +harness; the sandbox's own v6 deployer comes from the manifest. Normal unit tests collect the fork file as skipped +unless the opt-in flag is set. + +The `fork-smoke.test.ts` write-path checks are read-only: standard Governor proposal ordering, executed upgrade-path log +attestation, v5/v6 `openAuction` calldata, the live v6 `startRebalance` next nonce/deadline, and the v5 deploy builder. +They do not submit transactions or perform upgrades. SDK-built v6 deploys are executed by +`fork-smoke-v6-mutating.test.ts` against the sandbox deployer and by the real-deployer fork below. + +Execution readback is pinned to `stateBlock`. The generated fixture can have non-overlapping auction windows: the v5 +auction is active at its current state timestamp while the v6 auction is successfully opened and scheduled. The smoke +therefore asserts v5 through `getActiveAuction`, v6 through `getLatestAuction` with `getActiveAuction === null` before +its declared start, and exact event/readback start, end, duration, nonce, ID, and token equality for both. + +## Real Folio 6.0 Deployer Fork + +`fork-real-v6.test.ts` exercises the production 6.0.0 `FolioDeployer` behind `INDEX_DTF_V6_DEPLOYER_ADDRESS` on +disposable, unindexed Anvil forks of mainnet, Base and BSC (never the shared sandbox on 8545, which the suite refuses). +Per chain it: + +- checks the deployer's `folioImplementation()` runtime code against the explorer-verified 6.0.0 hash the SDK ABIs are + tested against (`src/index-dtf/abis/explorer/`); +- deploys an ungoverned Folio through the SDK's default deployer and reads the v6 state back; +- `eth_call`s every function of the SDK's v6 Folio ABI with well-formed arguments: an unknown selector (and the + pre-audit `endRebalance()`) reverts with empty data, so every function must return or revert with a decoded error; +- runs a complete rebalance with SDK builders: `startRebalance` at the expected nonce; a launcher `openAuction` with + bids at its start, middle and end; a no-op `closeAuction` after the end; `openAuctionUnrestricted` from a roleless + account, rejected before `restrictedUntil` and opened from it on spot limits, spot weights and initial prices; one + bid for everything available; a mid-auction `closeAuction`; `endRebalance(nonce)` after a stale nonce is rejected. + Every price and bid size is recomputed from contract reads (exact at an auction's start and end, the exponential + curve within 1e-9 between, the sell cap from limits, weights, supply and balances as Folio's `getBid` computes it), + and the final basket must sit within the spot limits and at least three quarters of the way to the 20/80 target; +- deploys a governed Folio (the deployer's optimistic governor deployer 1.1.0 wires governor, timelock and selector + registry), then registers the deployer in the chain's `FolioVersionRegistry` as the registry owner and reads 6.0.0 + back. + +Each chain runs inside one `evm_snapshot`/`evm_revert`, so the forks can be reused. Only Folio calls behind the +`sync` modifier (`startRebalance`, `openAuction`, `openAuctionUnrestricted`, `bid`) are sent with a fixed gas limit: +Anvil can estimate at one timestamp and mine the next, and `sync`'s `lastFolioFeePoke` write then costs more than the +estimate covered, starving the delegatecall into `RebalancingLib`. + +```sh +anvil --fork-url --port 8546 --chain-id 1 +anvil --fork-url --port 8547 --chain-id 8453 +anvil --fork-url --port 8548 --chain-id 56 +pnpm --filter @reserve-protocol/sdk test:smoke:index:fork-real-v6 +``` + +`INDEX_DTF_FORK_REAL_V6_RPC_URL_1`, `_8453` and `_56` override the loopback URLs. Run one chain with +`-t "chain 8453"`. The suite aborts before writing when the RPC is not Anvil or reports another chain id, which also +keeps it off Register's indexed fork stacks (Base on 8546, BSC on 8547); never point an override at an indexed fork. +When a chain's registry already has 6.0.0, the last case only checks that the registered deployer is the pinned one. + ## Errors SDK errors use stable machine-readable codes: diff --git a/packages/sdk/codegen.yml b/packages/sdk/codegen.yml index b6351ec..b9d0d47 100644 --- a/packages/sdk/codegen.yml +++ b/packages/sdk/codegen.yml @@ -28,7 +28,9 @@ generates: useTypeImports: true src/index-dtf/subgraph/dtf.generated.ts: # Intentionally tracks the prod Index subgraph schema; prod is the shared runtime tag across supported chains. - schema: https://api.goldsky.com/api/public/project_cmgzim3e100095np2gjnbh6ry/subgraphs/dtf-index-base/prod/gn + # INDEX_DTF_SUBGRAPH_SCHEMA points codegen (and graphql:codegen:check) at a candidate deployment before it is + # promoted to prod, e.g. .../dtf-index-base/1.11.1-test/gn. The default stays prod so CI keeps checking prod. + schema: ${INDEX_DTF_SUBGRAPH_SCHEMA:https://api.goldsky.com/api/public/project_cmgzim3e100095np2gjnbh6ry/subgraphs/dtf-index-base/prod/gn} documents: - src/index-dtf/subgraph/**/*.graphql plugins: diff --git a/packages/sdk/package.json b/packages/sdk/package.json index bb07607..414bb68 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -40,11 +40,16 @@ }, "scripts": { "build": "tsdown", + "check:package": "node scripts/check-package-entries.mjs", + "check:subgraph-endpoints": "node scripts/check-subgraph-endpoints.mjs", "clean": "rm -rf dist *.tsbuildinfo", "dev": "tsdown --watch", "graphql:codegen": "graphql-codegen --config codegen.yml", "playground:index": "tsx playground/index-dtf.ts", "test": "vitest run --passWithNoTests", + "test:smoke:index:fork": "RUN_INDEX_DTF_FORK_SMOKE=1 vitest run --passWithNoTests --no-file-parallelism src/index-dtf/fork-smoke.test.ts src/index-dtf/fork-smoke-v6.test.ts", + "test:smoke:index:fork-mutating": "RUN_INDEX_DTF_FORK_MUTATING=1 vitest run --passWithNoTests --no-file-parallelism src/index-dtf/fork-smoke-v6-mutating.test.ts", + "test:smoke:index:fork-real-v6": "RUN_INDEX_DTF_FORK_REAL_V6=1 vitest run --passWithNoTests --no-file-parallelism src/index-dtf/fork-real-v6.test.ts", "test:smoke:index": "RUN_INDEX_DTF_SMOKE=1 vitest run --passWithNoTests src/index-dtf/live-smoke.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit", "validate:yield": "tsx scripts/validate-yield-dtf.ts" @@ -52,7 +57,7 @@ "dependencies": { "@graphql-typed-document-node/core": "^3.2.0", "@reserve-protocol/dtf-catalog": "workspace:*", - "@reserve-protocol/dtf-rebalance-lib": "^3.2.1", + "@reserve-protocol/dtf-rebalance-lib": "^3.3.1", "decimal.js-light": "^2.5.1", "graphql": "^16.13.2", "graphql-request": "^7.4.0", diff --git a/packages/sdk/scripts/check-package-entries.mjs b/packages/sdk/scripts/check-package-entries.mjs new file mode 100644 index 0000000..071d997 --- /dev/null +++ b/packages/sdk/scripts/check-package-entries.mjs @@ -0,0 +1,96 @@ +// Packs @reserve-protocol/sdk the way npm will ship it, installs the tarball next to the workspace dependencies and +// loads it through both package entries: `import` (ESM) and `require` (CJS). Every export must exist in both with the +// same kind and the same value (functions by kind only); every exported ABI must be an array. Catches interop regressions such as CJS exporting `{ default: ABI }`. +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const sdkRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const REQUIRED_ABIS = ["folioV6Abi", "folioDeployerV6Abi", "folioVersionRegistryAbi", "folioArtifactAbi"]; + +assertDistIsFresh(); + +// Inside the package's own node_modules so the unpacked package resolves viem, zod, the catalog, etc. from the workspace. +const cacheRoot = join(sdkRoot, "node_modules", ".cache"); +mkdirSync(cacheRoot, { recursive: true }); +const workDirectory = mkdtempSync(join(cacheRoot, "package-entries-")); + +try { + execFileSync("pnpm", ["pack", "--pack-destination", workDirectory], { cwd: sdkRoot, stdio: "ignore" }); + const tarball = readdirSync(workDirectory).find((file) => file.endsWith(".tgz")); + if (!tarball) fail("pnpm pack produced no tarball"); + execFileSync("tar", ["-xzf", join(workDirectory, tarball), "-C", workDirectory]); + const installed = join(workDirectory, "node_modules", "@reserve-protocol"); + mkdirSync(installed, { recursive: true }); + renameSync(join(workDirectory, "package"), join(installed, "sdk")); + + const probe = join(workDirectory, "probe.mjs"); + writeFileSync( + probe, + [ + 'import { createHash } from "node:crypto";', + 'import { createRequire } from "node:module";', + 'import * as esm from "@reserve-protocol/sdk";', + 'const cjs = createRequire(import.meta.url)("@reserve-protocol/sdk");', + // Kind plus a digest of every non-function value: `{ default: x }` and other wrappers change both. + "const json = (value) => JSON.stringify(value, (_, item) => (typeof item === 'bigint' ? `${item}n` : item));", + "const describe = (value) => `${Array.isArray(value) ? 'array' : typeof value}${typeof value === 'function' ? '' : ` ${createHash('sha256').update(String(json(value))).digest('hex').slice(0, 16)}`}`;", + "const shape = (module) => Object.fromEntries(Object.keys(module).filter((key) => key !== 'default' && key !== '__esModule').sort().map((key) => [key, describe(module[key])]));", + "process.stdout.write(JSON.stringify({ esm: shape(esm), cjs: shape(cjs) }));", + ].join("\n"), + ); + const { esm, cjs } = JSON.parse(execFileSync(process.execPath, [probe], { cwd: workDirectory, encoding: "utf8" })); + + const problems = []; + const abiKeys = new Set([...REQUIRED_ABIS, ...[...Object.keys(esm), ...Object.keys(cjs)].filter(isAbiName)]); + for (const key of new Set([...Object.keys(esm), ...Object.keys(cjs), ...abiKeys])) { + const isArray = (shape) => shape?.startsWith("array ") ?? false; + if (esm[key] !== cjs[key] || (abiKeys.has(key) && !isArray(esm[key]))) { + problems.push( + `${key}: ESM ${esm[key] ?? "missing"}, CJS ${cjs[key] ?? "missing"}${abiKeys.has(key) ? " (ABIs must be arrays)" : ""}`, + ); + } + } + if (problems.length > 0) fail(`Packed ESM and CJS entries disagree:\n${problems.join("\n")}`); + + const abiCount = abiKeys.size; + process.stdout.write( + `package-entries: ${tarball} loads through import and require; ${Object.keys(esm).length} exports match, ${abiCount} ABIs are arrays\n`, + ); +} finally { + rmSync(workDirectory, { recursive: true, force: true }); +} + +/** A stale dist would certify old output: every non-test source must be older than both entry bundles. */ +function assertDistIsFresh() { + const entries = ["dist/index.mjs", "dist/index.cjs"].map((file) => { + try { + return statSync(join(sdkRoot, file)).mtimeMs; + } catch { + return fail(`${file} is missing; run \`pnpm --filter @reserve-protocol/sdk build\` first`); + } + }); + const builtAt = Math.min(...entries); + const newer = listFiles(join(sdkRoot, "src")).filter( + (file) => /\.tsx?$/.test(file) && !/\.test\.tsx?$/.test(file) && statSync(file).mtimeMs > builtAt, + ); + if (newer.length > 0) { + fail(`dist is older than ${newer.length} source file(s), e.g. ${newer[0]}; rebuild before checking the package`); + } +} + +function isAbiName(key) { + return /Abi(V\d+)?$/.test(key); +} + +function listFiles(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => + entry.isDirectory() ? listFiles(join(directory, entry.name)) : [join(directory, entry.name)], + ); +} + +// Throws instead of exiting so the `finally` above always removes the unpacked package. +function fail(message) { + throw new Error(message); +} diff --git a/packages/sdk/scripts/check-subgraph-endpoints.mjs b/packages/sdk/scripts/check-subgraph-endpoints.mjs new file mode 100644 index 0000000..01a3e09 --- /dev/null +++ b/packages/sdk/scripts/check-subgraph-endpoints.mjs @@ -0,0 +1,45 @@ +import { buildClientSchema, getIntrospectionQuery, parse, validate } from "graphql"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { INDEX_DTF_SUBGRAPH_URL, YIELD_DTF_SUBGRAPH_URL } from "../dist/index.mjs"; + +// Release guard: every subgraph endpoint the built SDK queries must accept the GraphQL documents it ships. Codegen only +// checks Base prod; publishing while mainnet or BSC prod still serves an older schema would fail every read there. +const sdkRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const targets = [ + { name: "index", urls: INDEX_DTF_SUBGRAPH_URL, document: "src/index-dtf/subgraph/dtf.graphql" }, + { name: "yield", urls: YIELD_DTF_SUBGRAPH_URL, document: "src/yield-dtf/subgraph/yield.graphql" }, +]; + +const problems = []; +let checked = 0; + +for (const target of targets) { + const document = parse(readFileSync(resolve(sdkRoot, target.document), "utf8")); + + for (const [chainId, url] of Object.entries(target.urls)) { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ query: getIntrospectionQuery() }), + }); + const body = await response.json(); + if (!response.ok || !body.data) { + problems.push(`${target.name} ${chainId} ${url}: introspection failed (${response.status})`); + continue; + } + + const errors = validate(buildClientSchema(body.data), document); + for (const error of errors) problems.push(`${target.name} ${chainId} ${url}: ${error.message}`); + checked += 1; + } +} + +if (problems.length > 0) { + process.stderr.write(`Subgraph endpoints reject the SDK's GraphQL documents:\n${problems.join("\n")}\n`); + process.exit(1); +} + +process.stdout.write(`subgraph-endpoints: ${checked} configured endpoints accept the SDK's GraphQL documents\n`); diff --git a/packages/sdk/scripts/sync-index-dtf-v6-abis.mjs b/packages/sdk/scripts/sync-index-dtf-v6-abis.mjs new file mode 100644 index 0000000..8a5b4dd --- /dev/null +++ b/packages/sdk/scripts/sync-index-dtf-v6-abis.mjs @@ -0,0 +1,229 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { keccak256, toBytes } from "viem"; + +const PROTOCOL_COMMIT = "7d97c807003c563038ec948e92eb43eabba43ef0"; +const sdkRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repositoryRoot = resolve(sdkRoot, "../.."); +const defaultProtocolRoot = resolve(repositoryRoot, "../index-protocol"); + +const targets = [ + { + artifact: "out/Folio.sol/Folio.json", + output: "src/index-dtf/abis/folio-v6.generated.ts", + exportName: "folioV6Abi", + expectedAbiSha256: "f8e371547b312b08507ef8c8e7d05f602b500cf685d471d67845be7de6678c78", + requiredFunctions: ["openAuction", "startRebalance", "endRebalance", "version"], + }, + { + artifact: "out/FolioDeployer.sol/FolioDeployer.json", + output: "src/index-dtf/abis/folio-deployer-v6.generated.ts", + exportName: "folioDeployerV6Abi", + expectedAbiSha256: "1a327726b0a65ebb40b1a5a2f322b7843718019ad675c7e0b9676f94828a7610", + requiredFunctions: ["deployFolio", "deployGovernedFolio", "versionRegistry"], + }, + { + artifact: "out/FolioVersionRegistry.sol/FolioVersionRegistry.json", + output: "src/index-dtf/abis/folio-version-registry.generated.ts", + exportName: "folioVersionRegistryAbi", + expectedAbiSha256: "21b00604ef98ae3e804e4d0e88252d2954cd459922a96f1859d8c432592faf7d", + requiredFunctions: ["getLatestVersion", "registerVersion", "isDeprecated"], + }, +]; + +const options = parseArgs(process.argv.slice(2)); + +if (options.checkGenerated) { + for (const target of targets) assertCheckedInModule(target); + process.stdout.write(`Verified checked-in Index DTF v6 ABI modules for ${PROTOCOL_COMMIT}\n`); + process.exit(0); +} + +const protocolRoot = resolve(options.protocolRoot ?? defaultProtocolRoot); +const actualCommit = execFileSync("git", ["-C", protocolRoot, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + +if (actualCommit !== PROTOCOL_COMMIT) { + fail(`Expected reserve-index-dtf commit ${PROTOCOL_COMMIT}, received ${actualCommit} at ${protocolRoot}`); +} + +let stale = false; + +for (const target of targets) { + const artifactPath = resolve(protocolRoot, target.artifact); + const outputPath = resolve(sdkRoot, target.output); + const artifact = JSON.parse(readFileSync(artifactPath, "utf8")); + + if (!Array.isArray(artifact.abi)) { + fail(`Artifact does not contain an ABI array: ${artifactPath}`); + } + + assertArtifactProvenance(target, artifact, artifactPath); + + const functionNames = new Set(artifact.abi.filter((entry) => entry.type === "function").map((entry) => entry.name)); + for (const functionName of target.requiredFunctions) { + if (!functionNames.has(functionName)) { + fail(`Artifact ${artifactPath} is missing expected function ${functionName}`); + } + } + + const generated = renderAbiModule(target, artifact.abi); + + if (options.check) { + let current; + try { + current = readFileSync(outputPath, "utf8"); + } catch { + current = undefined; + } + + if (current !== generated) { + stale = true; + process.stderr.write(`Generated ABI is stale: ${outputPath}\n`); + } + } else { + writeFileSync(outputPath, generated); + process.stdout.write(`Wrote ${outputPath}\n`); + } +} + +if (stale) { + fail("Run `pnpm abi:index-v6:sync -- --protocol-root ` and review the generated ABI diff."); +} + +process.stdout.write( + `${options.check ? "Verified" : "Synchronized"} Index DTF v6 ABIs from ${PROTOCOL_COMMIT} (${protocolRoot})\n`, +); + +function renderAbiModule(target, abi) { + return [ + "// Generated by packages/sdk/scripts/sync-index-dtf-v6-abis.mjs.", + `// Source: reserve-protocol/reserve-index-dtf@${PROTOCOL_COMMIT}`, + `// Artifact: ${target.artifact}`, + "// Do not edit by hand; run `pnpm abi:index-v6:sync`.", + `export const ${target.exportName} = ${JSON.stringify(abi, null, 2)} as const;`, + "", + ].join("\n"); +} + +function assertArtifactProvenance(target, artifact, artifactPath) { + const abiSha256 = sha256(JSON.stringify(artifact.abi)); + if (abiSha256 !== target.expectedAbiSha256) { + fail(`Artifact ABI hash mismatch for ${artifactPath}: expected ${target.expectedAbiSha256}, received ${abiSha256}`); + } + + let metadata; + try { + metadata = JSON.parse(artifact.rawMetadata); + } catch (error) { + fail(`Artifact rawMetadata is missing or invalid: ${artifactPath} (${String(error)})`); + } + + if (typeof metadata.sources !== "object" || metadata.sources === null) { + fail(`Artifact rawMetadata has no source hashes: ${artifactPath}`); + } + + for (const [sourcePath, sourceMetadata] of Object.entries(metadata.sources)) { + const expectedHash = sourceMetadata?.keccak256; + if (typeof expectedHash !== "string") { + fail(`Artifact source metadata has no hash for ${sourcePath}: ${artifactPath}`); + } + + const sourceFile = resolve(protocolRoot, sourcePath); + let source; + try { + source = readFileSync(sourceFile, "utf8"); + } catch (error) { + fail(`Unable to verify artifact source ${sourceFile}: ${String(error)}`); + } + + const actualHash = keccak256(toBytes(source)); + if (actualHash !== expectedHash) { + fail(`Artifact source hash mismatch for ${sourceFile}: expected ${expectedHash}, received ${actualHash}`); + } + } +} + +function assertCheckedInModule(target) { + const outputPath = resolve(sdkRoot, target.output); + let current; + try { + current = readFileSync(outputPath, "utf8"); + } catch (error) { + fail(`Unable to read generated ABI module ${outputPath}: ${String(error)}`); + } + + const prefix = [ + "// Generated by packages/sdk/scripts/sync-index-dtf-v6-abis.mjs.", + `// Source: reserve-protocol/reserve-index-dtf@${PROTOCOL_COMMIT}`, + `// Artifact: ${target.artifact}`, + "// Do not edit by hand; run `pnpm abi:index-v6:sync`.", + `export const ${target.exportName} = `, + ].join("\n"); + const suffix = " as const;\n"; + + if (!current.startsWith(prefix) || !current.endsWith(suffix)) { + fail(`Generated ABI module header or shape is stale: ${outputPath}`); + } + + let abi; + try { + abi = JSON.parse(current.slice(prefix.length, -suffix.length)); + } catch (error) { + fail(`Generated ABI module does not contain valid JSON: ${outputPath} (${String(error)})`); + } + + const abiSha256 = sha256(JSON.stringify(abi)); + if (abiSha256 !== target.expectedAbiSha256 || current !== renderAbiModule(target, abi)) { + fail(`Generated ABI module hash is stale: ${outputPath}`); + } +} + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function parseArgs(args) { + let check = false; + let checkGenerated = false; + let protocolRoot; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === "--") { + continue; + } + if (argument === "--check") { + check = true; + continue; + } + if (argument === "--check-generated") { + checkGenerated = true; + continue; + } + if (argument === "--protocol-root") { + protocolRoot = args[index + 1]; + if (!protocolRoot) fail("--protocol-root requires a path"); + index += 1; + continue; + } + if (argument?.startsWith("--protocol-root=")) { + protocolRoot = argument.slice("--protocol-root=".length); + continue; + } + fail(`Unknown argument: ${argument}`); + } + + if (checkGenerated && (check || protocolRoot)) { + fail("--check-generated cannot be combined with --check or --protocol-root"); + } + + return { check, checkGenerated, protocolRoot }; +} + +function fail(message) { + process.stderr.write(`${message}\n`); + process.exit(1); +} diff --git a/packages/sdk/src/client/explorer.test.ts b/packages/sdk/src/client/explorer.test.ts index 55e05f9..75489b8 100644 --- a/packages/sdk/src/client/explorer.test.ts +++ b/packages/sdk/src/client/explorer.test.ts @@ -41,8 +41,17 @@ describe("Dtf client explorer", () => { ); const explorer = createDtfClientExplorer({ etherscanApiKey: "key" }); - await expect(explorer.getContractMetadata({ chainId: 1, address: ADDRESS })).resolves.toMatchObject({ + await expect(explorer.getContractMetadata({ chainId: 1, address: ADDRESS })).resolves.toEqual({ contractName: "ExternalConfigurator", + abi: [ + { + type: "function", + name: "setFoo", + inputs: [{ name: "value", type: "uint256" }], + outputs: [], + stateMutability: "nonpayable", + }, + ], }); }); diff --git a/packages/sdk/src/index-dtf/abis/dtf-index-abi-v1.ts b/packages/sdk/src/index-dtf/abis/dtf-index-abi-v1.ts index 1c33738..f218685 100644 --- a/packages/sdk/src/index-dtf/abis/dtf-index-abi-v1.ts +++ b/packages/sdk/src/index-dtf/abis/dtf-index-abi-v1.ts @@ -2419,5 +2419,3 @@ export const dtfIndexAbiV1 = [ type: "function", }, ] as const; - -export default dtfIndexAbiV1; diff --git a/packages/sdk/src/index-dtf/abis/dtf-index-abi-v2.ts b/packages/sdk/src/index-dtf/abis/dtf-index-abi-v2.ts index 30609ac..704f2db 100644 --- a/packages/sdk/src/index-dtf/abis/dtf-index-abi-v2.ts +++ b/packages/sdk/src/index-dtf/abis/dtf-index-abi-v2.ts @@ -1321,5 +1321,3 @@ export const dtfIndexAbiV2 = [ inputs: [{ name: "token", type: "address", internalType: "address" }], }, ] as const; - -export default dtfIndexAbiV2; diff --git a/packages/sdk/src/index-dtf/abis/dtf-index-abi-v4.ts b/packages/sdk/src/index-dtf/abis/dtf-index-abi-v4.ts index 4f6fa3e..3ccebb6 100644 --- a/packages/sdk/src/index-dtf/abis/dtf-index-abi-v4.ts +++ b/packages/sdk/src/index-dtf/abis/dtf-index-abi-v4.ts @@ -1394,5 +1394,3 @@ export const dtfIndexAbiV4 = [ type: "function", }, ] as const; - -export default dtfIndexAbiV4; diff --git a/packages/sdk/src/index-dtf/abis/dtf-index-abi-v6.ts b/packages/sdk/src/index-dtf/abis/dtf-index-abi-v6.ts deleted file mode 100644 index 8f2a2ee..0000000 --- a/packages/sdk/src/index-dtf/abis/dtf-index-abi-v6.ts +++ /dev/null @@ -1,2813 +0,0 @@ -export default [ - { - type: "constructor", - inputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "DEFAULT_ADMIN_ROLE", - inputs: [], - outputs: [ - { - name: "", - type: "bytes32", - internalType: "bytes32", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "addToAllowlist", - inputs: [ - { - name: "tokens", - type: "address[]", - internalType: "address[]", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "addToBasket", - inputs: [ - { - name: "token", - type: "address", - internalType: "contract IERC20", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "allowance", - inputs: [ - { - name: "owner", - type: "address", - internalType: "address", - }, - { - name: "spender", - type: "address", - internalType: "address", - }, - ], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "approve", - inputs: [ - { - name: "spender", - type: "address", - internalType: "address", - }, - { - name: "value", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [ - { - name: "", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "auctions", - inputs: [ - { - name: "id", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [ - { - name: "rebalanceNonce", - type: "uint256", - internalType: "uint256", - }, - { - name: "startTime", - type: "uint256", - internalType: "uint256", - }, - { - name: "endTime", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "balanceOf", - inputs: [ - { - name: "account", - type: "address", - internalType: "address", - }, - ], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "bid", - inputs: [ - { - name: "auctionId", - type: "uint256", - internalType: "uint256", - }, - { - name: "sellToken", - type: "address", - internalType: "contract IERC20", - }, - { - name: "buyToken", - type: "address", - internalType: "contract IERC20", - }, - { - name: "sellAmount", - type: "uint256", - internalType: "uint256", - }, - { - name: "maxBuyAmount", - type: "uint256", - internalType: "uint256", - }, - { - name: "withCallback", - type: "bool", - internalType: "bool", - }, - { - name: "data", - type: "bytes", - internalType: "bytes", - }, - ], - outputs: [ - { - name: "boughtAmt", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "bidsEnabled", - inputs: [], - outputs: [ - { - name: "", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "closeAuction", - inputs: [ - { - name: "auctionId", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "createTrustedFill", - inputs: [ - { - name: "auctionId", - type: "uint256", - internalType: "uint256", - }, - { - name: "sellToken", - type: "address", - internalType: "contract IERC20", - }, - { - name: "buyToken", - type: "address", - internalType: "contract IERC20", - }, - { - name: "targetFiller", - type: "address", - internalType: "address", - }, - { - name: "deploymentSalt", - type: "bytes32", - internalType: "bytes32", - }, - ], - outputs: [ - { - name: "filler", - type: "address", - internalType: "contract IBaseTrustedFiller", - }, - ], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "daoFeeRegistry", - inputs: [], - outputs: [ - { - name: "", - type: "address", - internalType: "contract IFolioDAOFeeRegistry", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "daoPendingFeeShares", - inputs: [], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "decimals", - inputs: [], - outputs: [ - { - name: "", - type: "uint8", - internalType: "uint8", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "deprecateFolio", - inputs: [], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "distributeFees", - inputs: [], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "emergencyCloseTrustedFill", - inputs: [], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "endRebalance", - inputs: [], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "feeRecipients", - inputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [ - { - name: "recipient", - type: "address", - internalType: "address", - }, - { - name: "portion", - type: "uint96", - internalType: "uint96", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "feeRecipientsPendingFeeShares", - inputs: [], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "folioFeeForSelf", - inputs: [], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "getAuctionPrice", - inputs: [ - { - name: "auctionId", - type: "uint256", - internalType: "uint256", - }, - { - name: "token", - type: "address", - internalType: "address", - }, - ], - outputs: [ - { - name: "range", - type: "tuple", - internalType: "struct IFolio.PriceRange", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "getBid", - inputs: [ - { - name: "auctionId", - type: "uint256", - internalType: "uint256", - }, - { - name: "sellToken", - type: "address", - internalType: "contract IERC20", - }, - { - name: "buyToken", - type: "address", - internalType: "contract IERC20", - }, - { - name: "maxSellAmount", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [ - { - name: "sellAmount", - type: "uint256", - internalType: "uint256", - }, - { - name: "bidAmount", - type: "uint256", - internalType: "uint256", - }, - { - name: "price", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "getPendingFeeShares", - inputs: [], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "getRebalance", - inputs: [], - outputs: [ - { - name: "nonce", - type: "uint256", - internalType: "uint256", - }, - { - name: "priceControl", - type: "uint8", - internalType: "enum IFolio.PriceControl", - }, - { - name: "tokens", - type: "tuple[]", - internalType: "struct IFolio.TokenRebalanceParams[]", - components: [ - { - name: "token", - type: "address", - internalType: "address", - }, - { - name: "weight", - type: "tuple", - internalType: "struct IFolio.WeightRange", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "spot", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "price", - type: "tuple", - internalType: "struct IFolio.PriceRange", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "maxAuctionSize", - type: "uint256", - internalType: "uint256", - }, - { - name: "inRebalance", - type: "bool", - internalType: "bool", - }, - ], - }, - { - name: "limits", - type: "tuple", - internalType: "struct IFolio.RebalanceLimits", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "spot", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "timestamps", - type: "tuple", - internalType: "struct Folio.RebalanceTimestamps", - components: [ - { - name: "startedAt", - type: "uint256", - internalType: "uint256", - }, - { - name: "restrictedUntil", - type: "uint256", - internalType: "uint256", - }, - { - name: "availableUntil", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "bidsEnabled_", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "getRoleAdmin", - inputs: [ - { - name: "role", - type: "bytes32", - internalType: "bytes32", - }, - ], - outputs: [ - { - name: "", - type: "bytes32", - internalType: "bytes32", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "getRoleMember", - inputs: [ - { - name: "role", - type: "bytes32", - internalType: "bytes32", - }, - { - name: "index", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [ - { - name: "", - type: "address", - internalType: "address", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "getRoleMemberCount", - inputs: [ - { - name: "role", - type: "bytes32", - internalType: "bytes32", - }, - ], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "getRoleMembers", - inputs: [ - { - name: "role", - type: "bytes32", - internalType: "bytes32", - }, - ], - outputs: [ - { - name: "", - type: "address[]", - internalType: "address[]", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "getTokenAllowlist", - inputs: [], - outputs: [ - { - name: "", - type: "address[]", - internalType: "address[]", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "grantRole", - inputs: [ - { - name: "role", - type: "bytes32", - internalType: "bytes32", - }, - { - name: "account", - type: "address", - internalType: "address", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "hasRole", - inputs: [ - { - name: "role", - type: "bytes32", - internalType: "bytes32", - }, - { - name: "account", - type: "address", - internalType: "address", - }, - ], - outputs: [ - { - name: "", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "initialize", - inputs: [ - { - name: "_basicDetails", - type: "tuple", - internalType: "struct IFolio.FolioBasicDetails", - components: [ - { - name: "name", - type: "string", - internalType: "string", - }, - { - name: "symbol", - type: "string", - internalType: "string", - }, - { - name: "assets", - type: "address[]", - internalType: "address[]", - }, - { - name: "amounts", - type: "uint256[]", - internalType: "uint256[]", - }, - { - name: "initialShares", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "_additionalDetails", - type: "tuple", - internalType: "struct IFolio.FolioAdditionalDetails", - components: [ - { - name: "maxAuctionLength", - type: "uint256", - internalType: "uint256", - }, - { - name: "feeRecipients", - type: "tuple[]", - internalType: "struct IFolio.FeeRecipient[]", - components: [ - { - name: "recipient", - type: "address", - internalType: "address", - }, - { - name: "portion", - type: "uint96", - internalType: "uint96", - }, - ], - }, - { - name: "tvlFee", - type: "uint256", - internalType: "uint256", - }, - { - name: "mintFee", - type: "uint256", - internalType: "uint256", - }, - { - name: "folioFeeForSelf", - type: "uint256", - internalType: "uint256", - }, - { - name: "mandate", - type: "string", - internalType: "string", - }, - ], - }, - { - name: "_folioRegistries", - type: "tuple", - internalType: "struct IFolio.FolioRegistryIndex", - components: [ - { - name: "daoFeeRegistry", - type: "address", - internalType: "address", - }, - { - name: "trustedFillerRegistry", - type: "address", - internalType: "address", - }, - ], - }, - { - name: "_folioFlags", - type: "tuple", - internalType: "struct IFolio.FolioFlags", - components: [ - { - name: "trustedFillerEnabled", - type: "bool", - internalType: "bool", - }, - { - name: "rebalanceControl", - type: "tuple", - internalType: "struct IFolio.RebalanceControl", - components: [ - { - name: "weightControl", - type: "bool", - internalType: "bool", - }, - { - name: "priceControl", - type: "uint8", - internalType: "enum IFolio.PriceControl", - }, - ], - }, - { - name: "bidsEnabled", - type: "bool", - internalType: "bool", - }, - ], - }, - { - name: "_creator", - type: "address", - internalType: "address", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "isDeprecated", - inputs: [], - outputs: [ - { - name: "", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "isTokenAllowlisted", - inputs: [ - { - name: "token", - type: "address", - internalType: "address", - }, - ], - outputs: [ - { - name: "", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "lastPoke", - inputs: [], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "mandate", - inputs: [], - outputs: [ - { - name: "", - type: "string", - internalType: "string", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "maxAuctionLength", - inputs: [], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "mint", - inputs: [ - { - name: "shares", - type: "uint256", - internalType: "uint256", - }, - { - name: "receiver", - type: "address", - internalType: "address", - }, - { - name: "minSharesOut", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [ - { - name: "_assets", - type: "address[]", - internalType: "address[]", - }, - { - name: "_amounts", - type: "uint256[]", - internalType: "uint256[]", - }, - ], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "mintFee", - inputs: [], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "name", - inputs: [], - outputs: [ - { - name: "", - type: "string", - internalType: "string", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "nextAuctionId", - inputs: [], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "openAuction", - inputs: [ - { - name: "rebalanceNonce", - type: "uint256", - internalType: "uint256", - }, - { - name: "tokens", - type: "address[]", - internalType: "address[]", - }, - { - name: "newWeights", - type: "tuple[]", - internalType: "struct IFolio.WeightRange[]", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "spot", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "newPrices", - type: "tuple[]", - internalType: "struct IFolio.PriceRange[]", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "newLimits", - type: "tuple", - internalType: "struct IFolio.RebalanceLimits", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "spot", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "auctionLength", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [ - { - name: "auctionId", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "openAuctionUnrestricted", - inputs: [ - { - name: "rebalanceNonce", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [ - { - name: "auctionId", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "poke", - inputs: [], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "rebalanceControl", - inputs: [], - outputs: [ - { - name: "weightControl", - type: "bool", - internalType: "bool", - }, - { - name: "priceControl", - type: "uint8", - internalType: "enum IFolio.PriceControl", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "redeem", - inputs: [ - { - name: "shares", - type: "uint256", - internalType: "uint256", - }, - { - name: "receiver", - type: "address", - internalType: "address", - }, - { - name: "assets", - type: "address[]", - internalType: "address[]", - }, - { - name: "minAmountsOut", - type: "uint256[]", - internalType: "uint256[]", - }, - ], - outputs: [ - { - name: "_amounts", - type: "uint256[]", - internalType: "uint256[]", - }, - ], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "removeFromAllowlist", - inputs: [ - { - name: "tokens", - type: "address[]", - internalType: "address[]", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "removeFromBasket", - inputs: [ - { - name: "token", - type: "address", - internalType: "contract IERC20", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "renounceRole", - inputs: [ - { - name: "role", - type: "bytes32", - internalType: "bytes32", - }, - { - name: "callerConfirmation", - type: "address", - internalType: "address", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "revokeRole", - inputs: [ - { - name: "role", - type: "bytes32", - internalType: "bytes32", - }, - { - name: "account", - type: "address", - internalType: "address", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "setBidsEnabled", - inputs: [ - { - name: "_bidsEnabled", - type: "bool", - internalType: "bool", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "setFeeRecipients", - inputs: [ - { - name: "_newRecipients", - type: "tuple[]", - internalType: "struct IFolio.FeeRecipient[]", - components: [ - { - name: "recipient", - type: "address", - internalType: "address", - }, - { - name: "portion", - type: "uint96", - internalType: "uint96", - }, - ], - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "setFolioSelfFee", - inputs: [ - { - name: "_newFee", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "setMandate", - inputs: [ - { - name: "_newMandate", - type: "string", - internalType: "string", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "setMaxAuctionLength", - inputs: [ - { - name: "_newLength", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "setMintFee", - inputs: [ - { - name: "_newFee", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "setName", - inputs: [ - { - name: "_newName", - type: "string", - internalType: "string", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "setRebalanceControl", - inputs: [ - { - name: "_rebalanceControl", - type: "tuple", - internalType: "struct IFolio.RebalanceControl", - components: [ - { - name: "weightControl", - type: "bool", - internalType: "bool", - }, - { - name: "priceControl", - type: "uint8", - internalType: "enum IFolio.PriceControl", - }, - ], - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "setTVLFee", - inputs: [ - { - name: "_newFee", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "setTradeAllowlistEnabled", - inputs: [ - { - name: "_enabled", - type: "bool", - internalType: "bool", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "setTrustedFillerRegistry", - inputs: [ - { - name: "_newFillerRegistry", - type: "address", - internalType: "address", - }, - { - name: "_enabled", - type: "bool", - internalType: "bool", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "startRebalance", - inputs: [ - { - name: "tokens", - type: "tuple[]", - internalType: "struct IFolio.TokenRebalanceParams[]", - components: [ - { - name: "token", - type: "address", - internalType: "address", - }, - { - name: "weight", - type: "tuple", - internalType: "struct IFolio.WeightRange", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "spot", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "price", - type: "tuple", - internalType: "struct IFolio.PriceRange", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "maxAuctionSize", - type: "uint256", - internalType: "uint256", - }, - { - name: "inRebalance", - type: "bool", - internalType: "bool", - }, - ], - }, - { - name: "limits", - type: "tuple", - internalType: "struct IFolio.RebalanceLimits", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "spot", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "auctionLauncherWindow", - type: "uint256", - internalType: "uint256", - }, - { - name: "ttl", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "stateChangeActive", - inputs: [], - outputs: [ - { - name: "syncStateChangeActive", - type: "bool", - internalType: "bool", - }, - { - name: "asyncStateChangeActive", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "supportsInterface", - inputs: [ - { - name: "interfaceId", - type: "bytes4", - internalType: "bytes4", - }, - ], - outputs: [ - { - name: "", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "symbol", - inputs: [], - outputs: [ - { - name: "", - type: "string", - internalType: "string", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "toAssets", - inputs: [ - { - name: "shares", - type: "uint256", - internalType: "uint256", - }, - { - name: "rounding", - type: "uint8", - internalType: "enum Math.Rounding", - }, - ], - outputs: [ - { - name: "_assets", - type: "address[]", - internalType: "address[]", - }, - { - name: "_amounts", - type: "uint256[]", - internalType: "uint256[]", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "totalAssets", - inputs: [], - outputs: [ - { - name: "_assets", - type: "address[]", - internalType: "address[]", - }, - { - name: "_amounts", - type: "uint256[]", - internalType: "uint256[]", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "totalSupply", - inputs: [], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "tradeAllowlistEnabled", - inputs: [], - outputs: [ - { - name: "", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "transfer", - inputs: [ - { - name: "to", - type: "address", - internalType: "address", - }, - { - name: "value", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [ - { - name: "", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "transferFrom", - inputs: [ - { - name: "from", - type: "address", - internalType: "address", - }, - { - name: "to", - type: "address", - internalType: "address", - }, - { - name: "value", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [ - { - name: "", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "trustedFillerEnabled", - inputs: [], - outputs: [ - { - name: "", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "trustedFillerRegistry", - inputs: [], - outputs: [ - { - name: "", - type: "address", - internalType: "contract ITrustedFillerRegistry", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "tvlFee", - inputs: [], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "version", - inputs: [], - outputs: [ - { - name: "", - type: "string", - internalType: "string", - }, - ], - stateMutability: "pure", - }, - { - type: "event", - name: "Approval", - inputs: [ - { - name: "owner", - type: "address", - indexed: true, - internalType: "address", - }, - { - name: "spender", - type: "address", - indexed: true, - internalType: "address", - }, - { - name: "value", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "AuctionBid", - inputs: [ - { - name: "auctionId", - type: "uint256", - indexed: true, - internalType: "uint256", - }, - { - name: "sellToken", - type: "address", - indexed: true, - internalType: "address", - }, - { - name: "buyToken", - type: "address", - indexed: true, - internalType: "address", - }, - { - name: "sellAmount", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - { - name: "buyAmount", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "AuctionClosed", - inputs: [ - { - name: "auctionId", - type: "uint256", - indexed: true, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "AuctionOpened", - inputs: [ - { - name: "rebalanceNonce", - type: "uint256", - indexed: true, - internalType: "uint256", - }, - { - name: "auctionId", - type: "uint256", - indexed: true, - internalType: "uint256", - }, - { - name: "tokens", - type: "address[]", - indexed: false, - internalType: "address[]", - }, - { - name: "weights", - type: "tuple[]", - indexed: false, - internalType: "struct IFolio.WeightRange[]", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "spot", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "prices", - type: "tuple[]", - indexed: false, - internalType: "struct IFolio.PriceRange[]", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "limits", - type: "tuple", - indexed: false, - internalType: "struct IFolio.RebalanceLimits", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "spot", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "startTime", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - { - name: "endTime", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "AuctionTrustedFillCreated", - inputs: [ - { - name: "auctionId", - type: "uint256", - indexed: true, - internalType: "uint256", - }, - { - name: "filler", - type: "address", - indexed: false, - internalType: "address", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "BasketTokenAdded", - inputs: [ - { - name: "token", - type: "address", - indexed: true, - internalType: "address", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "BasketTokenRemoved", - inputs: [ - { - name: "token", - type: "address", - indexed: true, - internalType: "address", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "BidsEnabledSet", - inputs: [ - { - name: "bidsEnabled", - type: "bool", - indexed: false, - internalType: "bool", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "FeeRecipientsSet", - inputs: [ - { - name: "recipients", - type: "tuple[]", - indexed: false, - internalType: "struct IFolio.FeeRecipient[]", - components: [ - { - name: "recipient", - type: "address", - internalType: "address", - }, - { - name: "portion", - type: "uint96", - internalType: "uint96", - }, - ], - }, - ], - anonymous: false, - }, - { - type: "event", - name: "FolioDeprecated", - inputs: [], - anonymous: false, - }, - { - type: "event", - name: "FolioFeePaid", - inputs: [ - { - name: "recipient", - type: "address", - indexed: true, - internalType: "address", - }, - { - name: "amount", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "FolioFeeSet", - inputs: [ - { - name: "newFolioFee", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "Initialized", - inputs: [ - { - name: "version", - type: "uint64", - indexed: false, - internalType: "uint64", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "MandateSet", - inputs: [ - { - name: "newMandate", - type: "string", - indexed: false, - internalType: "string", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "MaxAuctionLengthSet", - inputs: [ - { - name: "newMaxAuctionLength", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "MintFeeSet", - inputs: [ - { - name: "newFee", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "NameSet", - inputs: [ - { - name: "newName", - type: "string", - indexed: false, - internalType: "string", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "ProtocolFeePaid", - inputs: [ - { - name: "recipient", - type: "address", - indexed: true, - internalType: "address", - }, - { - name: "amount", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "RebalanceControlSet", - inputs: [ - { - name: "newControl", - type: "tuple", - indexed: false, - internalType: "struct IFolio.RebalanceControl", - components: [ - { - name: "weightControl", - type: "bool", - internalType: "bool", - }, - { - name: "priceControl", - type: "uint8", - internalType: "enum IFolio.PriceControl", - }, - ], - }, - ], - anonymous: false, - }, - { - type: "event", - name: "RebalanceEnded", - inputs: [ - { - name: "nonce", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "RebalanceStarted", - inputs: [ - { - name: "nonce", - type: "uint256", - indexed: true, - internalType: "uint256", - }, - { - name: "priceControl", - type: "uint8", - indexed: false, - internalType: "enum IFolio.PriceControl", - }, - { - name: "tokens", - type: "tuple[]", - indexed: false, - internalType: "struct IFolio.TokenRebalanceParams[]", - components: [ - { - name: "token", - type: "address", - internalType: "address", - }, - { - name: "weight", - type: "tuple", - internalType: "struct IFolio.WeightRange", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "spot", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "price", - type: "tuple", - internalType: "struct IFolio.PriceRange", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "maxAuctionSize", - type: "uint256", - internalType: "uint256", - }, - { - name: "inRebalance", - type: "bool", - internalType: "bool", - }, - ], - }, - { - name: "limits", - type: "tuple", - indexed: false, - internalType: "struct IFolio.RebalanceLimits", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "spot", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "startedAt", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - { - name: "restrictedUntil", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - { - name: "availableUntil", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - { - name: "bidsEnabled", - type: "bool", - indexed: false, - internalType: "bool", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "RoleAdminChanged", - inputs: [ - { - name: "role", - type: "bytes32", - indexed: true, - internalType: "bytes32", - }, - { - name: "previousAdminRole", - type: "bytes32", - indexed: true, - internalType: "bytes32", - }, - { - name: "newAdminRole", - type: "bytes32", - indexed: true, - internalType: "bytes32", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "RoleGranted", - inputs: [ - { - name: "role", - type: "bytes32", - indexed: true, - internalType: "bytes32", - }, - { - name: "account", - type: "address", - indexed: true, - internalType: "address", - }, - { - name: "sender", - type: "address", - indexed: true, - internalType: "address", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "RoleRevoked", - inputs: [ - { - name: "role", - type: "bytes32", - indexed: true, - internalType: "bytes32", - }, - { - name: "account", - type: "address", - indexed: true, - internalType: "address", - }, - { - name: "sender", - type: "address", - indexed: true, - internalType: "address", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "TVLFeeSet", - inputs: [ - { - name: "newFee", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - { - name: "feeAnnually", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "TradeAllowlistEnabled", - inputs: [ - { - name: "enabled", - type: "bool", - indexed: false, - internalType: "bool", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "TradeAllowlistTokenAdded", - inputs: [ - { - name: "token", - type: "address", - indexed: true, - internalType: "address", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "TradeAllowlistTokenRemoved", - inputs: [ - { - name: "token", - type: "address", - indexed: true, - internalType: "address", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "Transfer", - inputs: [ - { - name: "from", - type: "address", - indexed: true, - internalType: "address", - }, - { - name: "to", - type: "address", - indexed: true, - internalType: "address", - }, - { - name: "value", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "TrustedFillerRegistrySet", - inputs: [ - { - name: "trustedFillerRegistry", - type: "address", - indexed: false, - internalType: "address", - }, - { - name: "isEnabled", - type: "bool", - indexed: false, - internalType: "bool", - }, - ], - anonymous: false, - }, - { - type: "error", - name: "AccessControlBadConfirmation", - inputs: [], - }, - { - type: "error", - name: "AccessControlUnauthorizedAccount", - inputs: [ - { - name: "account", - type: "address", - internalType: "address", - }, - { - name: "neededRole", - type: "bytes32", - internalType: "bytes32", - }, - ], - }, - { - type: "error", - name: "ERC20InsufficientAllowance", - inputs: [ - { - name: "spender", - type: "address", - internalType: "address", - }, - { - name: "allowance", - type: "uint256", - internalType: "uint256", - }, - { - name: "needed", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - type: "error", - name: "ERC20InsufficientBalance", - inputs: [ - { - name: "sender", - type: "address", - internalType: "address", - }, - { - name: "balance", - type: "uint256", - internalType: "uint256", - }, - { - name: "needed", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - type: "error", - name: "ERC20InvalidApprover", - inputs: [ - { - name: "approver", - type: "address", - internalType: "address", - }, - ], - }, - { - type: "error", - name: "ERC20InvalidReceiver", - inputs: [ - { - name: "receiver", - type: "address", - internalType: "address", - }, - ], - }, - { - type: "error", - name: "ERC20InvalidSender", - inputs: [ - { - name: "sender", - type: "address", - internalType: "address", - }, - ], - }, - { - type: "error", - name: "ERC20InvalidSpender", - inputs: [ - { - name: "spender", - type: "address", - internalType: "address", - }, - ], - }, - { - type: "error", - name: "Folio__AuctionCannotBeOpenedWithoutRestriction", - inputs: [], - }, - { - type: "error", - name: "Folio__AuctionNotOngoing", - inputs: [], - }, - { - type: "error", - name: "Folio__BadFeeTotal", - inputs: [], - }, - { - type: "error", - name: "Folio__BalanceNotRemovable", - inputs: [], - }, - { - type: "error", - name: "Folio__BasketModificationFailed", - inputs: [], - }, - { - type: "error", - name: "Folio__DuplicateAsset", - inputs: [], - }, - { - type: "error", - name: "Folio__EmptyAssets", - inputs: [], - }, - { - type: "error", - name: "Folio__EmptyRebalance", - inputs: [], - }, - { - type: "error", - name: "Folio__FeeRecipientInvalidAddress", - inputs: [], - }, - { - type: "error", - name: "Folio__FeeRecipientInvalidFeeShare", - inputs: [], - }, - { - type: "error", - name: "Folio__FolioDeprecated", - inputs: [], - }, - { - type: "error", - name: "Folio__FolioFeeTooHigh", - inputs: [], - }, - { - type: "error", - name: "Folio__InsufficientBid", - inputs: [], - }, - { - type: "error", - name: "Folio__InsufficientBuyAvailable", - inputs: [], - }, - { - type: "error", - name: "Folio__InsufficientSellAvailable", - inputs: [], - }, - { - type: "error", - name: "Folio__InsufficientSharesOut", - inputs: [], - }, - { - type: "error", - name: "Folio__InvalidArrayLengths", - inputs: [], - }, - { - type: "error", - name: "Folio__InvalidAsset", - inputs: [], - }, - { - type: "error", - name: "Folio__InvalidAssetAmount", - inputs: [ - { - name: "asset", - type: "address", - internalType: "address", - }, - ], - }, - { - type: "error", - name: "Folio__InvalidAuctionLength", - inputs: [], - }, - { - type: "error", - name: "Folio__InvalidLimits", - inputs: [], - }, - { - type: "error", - name: "Folio__InvalidPrices", - inputs: [], - }, - { - type: "error", - name: "Folio__InvalidRegistry", - inputs: [], - }, - { - type: "error", - name: "Folio__InvalidTTL", - inputs: [], - }, - { - type: "error", - name: "Folio__InvalidTransferToSelf", - inputs: [], - }, - { - type: "error", - name: "Folio__InvalidWeights", - inputs: [], - }, - { - type: "error", - name: "Folio__MintFeeTooHigh", - inputs: [], - }, - { - type: "error", - name: "Folio__MixedAtomicSwaps", - inputs: [], - }, - { - type: "error", - name: "Folio__NotRebalancing", - inputs: [], - }, - { - type: "error", - name: "Folio__PermissionlessBidsDisabled", - inputs: [], - }, - { - type: "error", - name: "Folio__SlippageExceeded", - inputs: [], - }, - { - type: "error", - name: "Folio__TVLFeeTooHigh", - inputs: [], - }, - { - type: "error", - name: "Folio__TVLFeeTooLow", - inputs: [], - }, - { - type: "error", - name: "Folio__TokenNotAllowlisted", - inputs: [], - }, - { - type: "error", - name: "Folio__TooManyFeeRecipients", - inputs: [], - }, - { - type: "error", - name: "Folio__TrustedFillerRegistryAlreadySet", - inputs: [], - }, - { - type: "error", - name: "Folio__TrustedFillerRegistryNotEnabled", - inputs: [], - }, - { - type: "error", - name: "Folio__Unauthorized", - inputs: [], - }, - { - type: "error", - name: "Folio__ZeroInitialShares", - inputs: [], - }, - { - type: "error", - name: "Folo__NotInRebalance", - inputs: [], - }, - { - type: "error", - name: "InvalidInitialization", - inputs: [], - }, - { - type: "error", - name: "NotInitializing", - inputs: [], - }, - { - type: "error", - name: "ReentrancyGuardReentrantCall", - inputs: [], - }, - { - type: "error", - name: "SafeERC20FailedOperation", - inputs: [ - { - name: "token", - type: "address", - internalType: "address", - }, - ], - }, -] as const; diff --git a/packages/sdk/src/index-dtf/abis/dtf-index-abi.ts b/packages/sdk/src/index-dtf/abis/dtf-index-abi.ts index ee9d5ba..c7bc6d0 100644 --- a/packages/sdk/src/index-dtf/abis/dtf-index-abi.ts +++ b/packages/sdk/src/index-dtf/abis/dtf-index-abi.ts @@ -1471,5 +1471,3 @@ export const dtfIndexAbi = [ inputs: [{ name: "token", type: "address", internalType: "address" }], }, ] as const; - -export default dtfIndexAbi; diff --git a/packages/sdk/src/index-dtf/abis/dtf-index-governance.ts b/packages/sdk/src/index-dtf/abis/dtf-index-governance.ts index a53dffd..b4085c2 100644 --- a/packages/sdk/src/index-dtf/abis/dtf-index-governance.ts +++ b/packages/sdk/src/index-dtf/abis/dtf-index-governance.ts @@ -1658,5 +1658,3 @@ export const dtfIndexGovernanceAbi = [ ], }, ] as const; - -export default dtfIndexGovernanceAbi; diff --git a/packages/sdk/src/index-dtf/abis/dtf-index-staking-vault.ts b/packages/sdk/src/index-dtf/abis/dtf-index-staking-vault.ts index 1803e0a..9a3e342 100644 --- a/packages/sdk/src/index-dtf/abis/dtf-index-staking-vault.ts +++ b/packages/sdk/src/index-dtf/abis/dtf-index-staking-vault.ts @@ -1822,5 +1822,3 @@ export const dtfIndexStakingVaultAbi = [ ], }, ] as const; - -export default dtfIndexStakingVaultAbi; diff --git a/packages/sdk/src/index-dtf/abis/explorer-abi.test.ts b/packages/sdk/src/index-dtf/abis/explorer-abi.test.ts new file mode 100644 index 0000000..e3e8578 --- /dev/null +++ b/packages/sdk/src/index-dtf/abis/explorer-abi.test.ts @@ -0,0 +1,69 @@ +import { toFunctionSelector, type Abi, type AbiParameter } from "viem"; +import { describe, expect, it } from "vitest"; + +import { readExplorerAbiFixture } from "@/index-dtf/abis/explorer-fixture"; +import { folioDeployerV6Abi } from "@/index-dtf/abis/folio-deployer-v6.generated"; +import { folioV6Abi } from "@/index-dtf/abis/folio-v6.generated"; + +// Any drift between the generated v6 ABIs and the explorer-verified 6.0.0 deployment fails here. +const folioFixture = readExplorerAbiFixture("folio-6.0.0.base.json"); +const deployerFixture = readExplorerAbiFixture("folio-deployer-6.0.0.base.json"); + +describe("Folio 6.0.0 ABIs against the explorer-verified deployment", () => { + it("pins the fixtures to the verified Base 6.0.0 contracts", () => { + expect(folioFixture).toMatchObject({ + contract: "Folio", + version: "6.0.0", + chainId: 8453, + address: "0xfD2e67d50ef57721f6777A1A6e648FD734f5BF73", + runtimeCodeKeccak256: "0xa142ca5d0c7450e00273d11fdd01b109756a098fb72403ab4a58b3ba5ef08ff5", + }); + expect(deployerFixture).toMatchObject({ + contract: "FolioDeployer", + version: "6.0.0", + chainId: 8453, + address: "0x4c891fCa6319d492866672E3D2AfdAAA5bDcfF67", + }); + }); + + it.each([ + ["Folio", folioV6Abi, folioFixture], + ["FolioDeployer", folioDeployerV6Abi, deployerFixture], + ] as const)("the generated %s ABI equals the deployed one item for item", (_, sdkAbi, fixture) => { + expect(canonicalAbi(sdkAbi as Abi)).toEqual(canonicalAbi(fixture.abi)); + }); + + it("encodes the deployed endRebalance(uint256), not the pre-audit endRebalance()", () => { + const selectors = (folioV6Abi as Abi) + .filter((item) => item.type === "function" && item.name === "endRebalance") + .map((item) => toFunctionSelector(item as never)); + + expect(selectors).toEqual([toFunctionSelector("endRebalance(uint256)")]); + }); +}); + +/** ABI items as sorted canonical strings: `internalType` and key order are compiler/explorer noise, not ABI. */ +function canonicalAbi(abi: Abi): readonly string[] { + return abi.map((item) => JSON.stringify(canonicalItem(item))).sort(); +} + +function canonicalItem(item: Abi[number]) { + return { + type: item.type, + name: "name" in item ? item.name : undefined, + inputs: "inputs" in item ? (item.inputs as readonly AbiParameter[]).map(canonicalParameter) : undefined, + outputs: "outputs" in item ? (item.outputs as readonly AbiParameter[]).map(canonicalParameter) : undefined, + stateMutability: "stateMutability" in item ? item.stateMutability : undefined, + anonymous: "anonymous" in item ? Boolean(item.anonymous) : undefined, + }; +} + +function canonicalParameter(parameter: AbiParameter): unknown { + return { + name: parameter.name ?? "", + type: parameter.type, + indexed: "indexed" in parameter ? parameter.indexed : undefined, + components: + "components" in parameter ? (parameter.components as readonly AbiParameter[]).map(canonicalParameter) : undefined, + }; +} diff --git a/packages/sdk/src/index-dtf/abis/explorer-fixture.ts b/packages/sdk/src/index-dtf/abis/explorer-fixture.ts new file mode 100644 index 0000000..21adbd3 --- /dev/null +++ b/packages/sdk/src/index-dtf/abis/explorer-fixture.ts @@ -0,0 +1,29 @@ +/// + +import type { Abi, Address, Hex } from "viem"; + +import { readFileSync } from "node:fs"; + +/** + * Test support, not part of the package: the Base Blockscout verified Folio 6.0.0 contracts the SDK encodes against + * (source URL, address and runtime-code hash inside each `explorer/*.json`). The Folio runtime code is identical on + * mainnet, Base and BSC. + */ +export type ExplorerAbiFixture = { + readonly contract: string; + readonly version: string; + readonly chainId: number; + readonly address: Address; + readonly source: string; + readonly verifiedAt: string; + readonly compiler: string; + readonly sourceFile: string; + readonly runtimeCodeKeccak256: Hex; + readonly abi: Abi; +}; + +export function readExplorerAbiFixture( + file: "folio-6.0.0.base.json" | "folio-deployer-6.0.0.base.json", +): ExplorerAbiFixture { + return JSON.parse(readFileSync(new URL(`./explorer/${file}`, import.meta.url), "utf8")) as ExplorerAbiFixture; +} diff --git a/packages/sdk/src/index-dtf/abis/explorer/folio-6.0.0.base.json b/packages/sdk/src/index-dtf/abis/explorer/folio-6.0.0.base.json new file mode 100644 index 0000000..360774d --- /dev/null +++ b/packages/sdk/src/index-dtf/abis/explorer/folio-6.0.0.base.json @@ -0,0 +1,2978 @@ +{ + "contract": "Folio", + "version": "6.0.0", + "chainId": 8453, + "address": "0xfD2e67d50ef57721f6777A1A6e648FD734f5BF73", + "source": "https://base.blockscout.com/api/v2/smart-contracts/0xfD2e67d50ef57721f6777A1A6e648FD734f5BF73", + "verifiedAt": "2026-09-29T22:31:37.365756Z", + "compiler": "v0.8.28+commit.7893614a", + "sourceFile": "lib/reserve-index-dtf/contracts/Folio.sol", + "runtimeCodeKeccak256": "0xa142ca5d0c7450e00273d11fdd01b109756a098fb72403ab4a58b3ba5ef08ff5", + "abi": [ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "AccessControlBadConfirmation", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "neededRole", + "type": "bytes32" + } + ], + "name": "AccessControlUnauthorizedAccount", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "allowance", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "needed", + "type": "uint256" + } + ], + "name": "ERC20InsufficientAllowance", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "balance", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "needed", + "type": "uint256" + } + ], + "name": "ERC20InsufficientBalance", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "approver", + "type": "address" + } + ], + "name": "ERC20InvalidApprover", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "name": "ERC20InvalidReceiver", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "ERC20InvalidSender", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "ERC20InvalidSpender", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__AuctionCannotBeOpenedWithoutRestriction", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__AuctionNotOngoing", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__BadFeeTotal", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__DeadlineExpired", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__DuplicateAsset", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__EmptyAssets", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__EmptyRebalance", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__FeeRecipientInvalidAddress", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__FeeRecipientInvalidFeeShare", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__FolioDeprecated", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__FolioFeeTooHigh", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__ImmutableFeeRecipientRemoved", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__InsufficientBid", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__InsufficientBuyAvailable", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__InsufficientSellAvailable", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__InsufficientSharesOut", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__InvalidArrayLengths", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__InvalidAsset", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "asset", + "type": "address" + } + ], + "name": "Folio__InvalidAssetAmount", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__InvalidAuctionLength", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__InvalidLimits", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__InvalidPrices", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__InvalidRebalanceNonce", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__InvalidRegistry", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__InvalidTTL", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__InvalidTransferToSelf", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__InvalidTrustedFill", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__InvalidWeights", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__MintFeeTooHigh", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__MixedAtomicSwaps", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__NotInRebalance", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__NotRebalancing", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__PermissionlessBidsDisabled", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__SlippageExceeded", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__TVLFeeTooHigh", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__TVLFeeTooLow", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__TokenNotAllowlisted", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__TooManyFeeRecipients", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__TrustedFillerRegistryAlreadySet", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__TrustedFillerRegistryNotEnabled", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__Unauthorized", + "type": "error" + }, + { + "inputs": [], + "name": "Folio__ZeroInitialShares", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidInitialization", + "type": "error" + }, + { + "inputs": [], + "name": "NotInitializing", + "type": "error" + }, + { + "inputs": [], + "name": "ReentrancyGuardReentrantCall", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "SafeERC20FailedOperation", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "auctionId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "sellToken", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "buyToken", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "sellAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "buyAmount", + "type": "uint256" + } + ], + "name": "AuctionBid", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "auctionId", + "type": "uint256" + } + ], + "name": "AuctionClosed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "rebalanceNonce", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "auctionId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address[]", + "name": "tokens", + "type": "address[]" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "low", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "spot", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "high", + "type": "uint256" + } + ], + "indexed": false, + "internalType": "struct IFolio.WeightRange[]", + "name": "weights", + "type": "tuple[]" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "low", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "high", + "type": "uint256" + } + ], + "indexed": false, + "internalType": "struct IFolio.PriceRange[]", + "name": "prices", + "type": "tuple[]" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "low", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "spot", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "high", + "type": "uint256" + } + ], + "indexed": false, + "internalType": "struct IFolio.RebalanceLimits", + "name": "limits", + "type": "tuple" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "startTime", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "endTime", + "type": "uint256" + } + ], + "name": "AuctionOpened", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "auctionId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "filler", + "type": "address" + } + ], + "name": "AuctionTrustedFillCreated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "BasketTokenAdded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "BasketTokenRemoved", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "bool", + "name": "bidsEnabled", + "type": "bool" + } + ], + "name": "BidsEnabledSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint96", + "name": "portion", + "type": "uint96" + } + ], + "indexed": false, + "internalType": "struct IFolio.FeeRecipient[]", + "name": "recipients", + "type": "tuple[]" + } + ], + "name": "FeeRecipientsSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [], + "name": "FolioDeprecated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "FolioFeePaid", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "newFolioFee", + "type": "uint256" + } + ], + "name": "FolioFeeSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint96", + "name": "portion", + "type": "uint96" + } + ], + "indexed": false, + "internalType": "struct IFolio.FeeRecipient[]", + "name": "recipients", + "type": "tuple[]" + } + ], + "name": "ImmutableFeeRecipientsSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint64", + "name": "version", + "type": "uint64" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "string", + "name": "newMandate", + "type": "string" + } + ], + "name": "MandateSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "newMaxAuctionLength", + "type": "uint256" + } + ], + "name": "MaxAuctionLengthSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "newFee", + "type": "uint256" + } + ], + "name": "MintFeeSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "string", + "name": "newName", + "type": "string" + } + ], + "name": "NameSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "ProtocolFeePaid", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "components": [ + { + "internalType": "bool", + "name": "weightControl", + "type": "bool" + }, + { + "internalType": "enum IFolio.PriceControl", + "name": "priceControl", + "type": "uint8" + } + ], + "indexed": false, + "internalType": "struct IFolio.RebalanceControl", + "name": "newControl", + "type": "tuple" + } + ], + "name": "RebalanceControlSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + } + ], + "name": "RebalanceEnded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "enum IFolio.PriceControl", + "name": "priceControl", + "type": "uint8" + }, + { + "components": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "low", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "spot", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "high", + "type": "uint256" + } + ], + "internalType": "struct IFolio.WeightRange", + "name": "weight", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "low", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "high", + "type": "uint256" + } + ], + "internalType": "struct IFolio.PriceRange", + "name": "price", + "type": "tuple" + }, + { + "internalType": "uint256", + "name": "maxAuctionSize", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "inRebalance", + "type": "bool" + } + ], + "indexed": false, + "internalType": "struct IFolio.TokenRebalanceParams[]", + "name": "tokens", + "type": "tuple[]" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "low", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "spot", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "high", + "type": "uint256" + } + ], + "indexed": false, + "internalType": "struct IFolio.RebalanceLimits", + "name": "limits", + "type": "tuple" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "startedAt", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "restrictedUntil", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "availableUntil", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bool", + "name": "bidsEnabled", + "type": "bool" + } + ], + "name": "RebalanceStarted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "previousAdminRole", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "newAdminRole", + "type": "bytes32" + } + ], + "name": "RoleAdminChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleGranted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleRevoked", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "newFee", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "feeAnnually", + "type": "uint256" + } + ], + "name": "TVLFeeSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "bool", + "name": "enabled", + "type": "bool" + } + ], + "name": "TradeAllowlistEnabled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "TradeAllowlistTokenAdded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "TradeAllowlistTokenRemoved", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "trustedFillerRegistry", + "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "isEnabled", + "type": "bool" + } + ], + "name": "TrustedFillerRegistrySet", + "type": "event" + }, + { + "inputs": [], + "name": "DEFAULT_ADMIN_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "tokens", + "type": "address[]" + } + ], + "name": "addToAllowlist", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract IERC20", + "name": "token", + "type": "address" + } + ], + "name": "addToBasket", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "id", + "type": "uint256" + } + ], + "name": "auctions", + "outputs": [ + { + "internalType": "uint256", + "name": "rebalanceNonce", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "startTime", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "endTime", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "auctionId", + "type": "uint256" + }, + { + "internalType": "contract IERC20", + "name": "sellToken", + "type": "address" + }, + { + "internalType": "contract IERC20", + "name": "buyToken", + "type": "address" + }, + { + "internalType": "uint256", + "name": "sellAmount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "maxBuyAmount", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "withCallback", + "type": "bool" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "bid", + "outputs": [ + { + "internalType": "uint256", + "name": "boughtAmt", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "bidsEnabled", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "auctionId", + "type": "uint256" + } + ], + "name": "closeAuction", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "auctionId", + "type": "uint256" + }, + { + "internalType": "contract IERC20", + "name": "sellToken", + "type": "address" + }, + { + "internalType": "contract IERC20", + "name": "buyToken", + "type": "address" + }, + { + "internalType": "address", + "name": "targetFiller", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "deploymentSalt", + "type": "bytes32" + } + ], + "name": "createTrustedFill", + "outputs": [ + { + "internalType": "contract IBaseTrustedFiller", + "name": "filler", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "daoFeeRegistry", + "outputs": [ + { + "internalType": "contract IFolioDAOFeeRegistry", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "daoPendingFeeShares", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "deprecateFolio", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "distributeFees", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "trustedFill", + "type": "address" + } + ], + "name": "emergencyCloseTrustedFill", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "rebalanceNonce", + "type": "uint256" + } + ], + "name": "endRebalance", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "feeRecipients", + "outputs": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint96", + "name": "portion", + "type": "uint96" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "feeRecipientsPendingFeeShares", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "folioFeeForSelf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "folioPendingMintFeeShares", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "auctionId", + "type": "uint256" + }, + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "getAuctionPrice", + "outputs": [ + { + "components": [ + { + "internalType": "uint256", + "name": "low", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "high", + "type": "uint256" + } + ], + "internalType": "struct IFolio.PriceRange", + "name": "range", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "auctionId", + "type": "uint256" + }, + { + "internalType": "contract IERC20", + "name": "sellToken", + "type": "address" + }, + { + "internalType": "contract IERC20", + "name": "buyToken", + "type": "address" + }, + { + "internalType": "uint256", + "name": "maxSellAmount", + "type": "uint256" + } + ], + "name": "getBid", + "outputs": [ + { + "internalType": "uint256", + "name": "sellAmount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "bidAmount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "price", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getPendingFeeShares", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getRebalance", + "outputs": [ + { + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, + { + "internalType": "enum IFolio.PriceControl", + "name": "priceControl", + "type": "uint8" + }, + { + "components": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "low", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "spot", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "high", + "type": "uint256" + } + ], + "internalType": "struct IFolio.WeightRange", + "name": "weight", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "low", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "high", + "type": "uint256" + } + ], + "internalType": "struct IFolio.PriceRange", + "name": "price", + "type": "tuple" + }, + { + "internalType": "uint256", + "name": "maxAuctionSize", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "inRebalance", + "type": "bool" + } + ], + "internalType": "struct IFolio.TokenRebalanceParams[]", + "name": "tokens", + "type": "tuple[]" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "low", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "spot", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "high", + "type": "uint256" + } + ], + "internalType": "struct IFolio.RebalanceLimits", + "name": "limits", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "startedAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "restrictedUntil", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "availableUntil", + "type": "uint256" + } + ], + "internalType": "struct Folio.RebalanceTimestamps", + "name": "timestamps", + "type": "tuple" + }, + { + "internalType": "bool", + "name": "bidsEnabled_", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getRebalanceNonce", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + } + ], + "name": "getRoleAdmin", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + } + ], + "name": "getRoleMember", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + } + ], + "name": "getRoleMemberCount", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + } + ], + "name": "getRoleMembers", + "outputs": [ + { + "internalType": "address[]", + "name": "", + "type": "address[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getTokenAllowlist", + "outputs": [ + { + "internalType": "address[]", + "name": "", + "type": "address[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "grantRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "hasRole", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "immutableFeeRecipients", + "outputs": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint96", + "name": "portion", + "type": "uint96" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "internalType": "string", + "name": "symbol", + "type": "string" + }, + { + "internalType": "address[]", + "name": "assets", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "amounts", + "type": "uint256[]" + }, + { + "internalType": "uint256", + "name": "initialShares", + "type": "uint256" + } + ], + "internalType": "struct IFolio.FolioBasicDetails", + "name": "_basicDetails", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "maxAuctionLength", + "type": "uint256" + }, + { + "components": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint96", + "name": "portion", + "type": "uint96" + } + ], + "internalType": "struct IFolio.FeeRecipient[]", + "name": "feeRecipients", + "type": "tuple[]" + }, + { + "components": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint96", + "name": "portion", + "type": "uint96" + } + ], + "internalType": "struct IFolio.FeeRecipient[]", + "name": "immutableFeeRecipients", + "type": "tuple[]" + }, + { + "internalType": "uint256", + "name": "tvlFee", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "mintFee", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "folioFeeForSelf", + "type": "uint256" + }, + { + "internalType": "string", + "name": "mandate", + "type": "string" + } + ], + "internalType": "struct IFolio.FolioAdditionalDetails", + "name": "_additionalDetails", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "address", + "name": "daoFeeRegistry", + "type": "address" + }, + { + "internalType": "address", + "name": "trustedFillerRegistry", + "type": "address" + } + ], + "internalType": "struct IFolio.FolioRegistryIndex", + "name": "_folioRegistries", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "bool", + "name": "trustedFillerEnabled", + "type": "bool" + }, + { + "components": [ + { + "internalType": "bool", + "name": "weightControl", + "type": "bool" + }, + { + "internalType": "enum IFolio.PriceControl", + "name": "priceControl", + "type": "uint8" + } + ], + "internalType": "struct IFolio.RebalanceControl", + "name": "rebalanceControl", + "type": "tuple" + }, + { + "internalType": "bool", + "name": "bidsEnabled", + "type": "bool" + } + ], + "internalType": "struct IFolio.FolioFlags", + "name": "_folioFlags", + "type": "tuple" + }, + { + "internalType": "address", + "name": "_creator", + "type": "address" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "isDeprecated", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "isTokenAllowlisted", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "lastFolioFeePoke", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "lastPoke", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "mandate", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "maxAuctionLength", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + }, + { + "internalType": "address", + "name": "receiver", + "type": "address" + }, + { + "internalType": "uint256", + "name": "minSharesOut", + "type": "uint256" + } + ], + "name": "mint", + "outputs": [ + { + "internalType": "address[]", + "name": "_assets", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "_amounts", + "type": "uint256[]" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "mintFee", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "nextAuctionId", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "rebalanceNonce", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "tokens", + "type": "address[]" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "low", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "spot", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "high", + "type": "uint256" + } + ], + "internalType": "struct IFolio.WeightRange[]", + "name": "newWeights", + "type": "tuple[]" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "low", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "high", + "type": "uint256" + } + ], + "internalType": "struct IFolio.PriceRange[]", + "name": "newPrices", + "type": "tuple[]" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "low", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "spot", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "high", + "type": "uint256" + } + ], + "internalType": "struct IFolio.RebalanceLimits", + "name": "newLimits", + "type": "tuple" + }, + { + "internalType": "uint256", + "name": "auctionLength", + "type": "uint256" + } + ], + "name": "openAuction", + "outputs": [ + { + "internalType": "uint256", + "name": "auctionId", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "rebalanceNonce", + "type": "uint256" + } + ], + "name": "openAuctionUnrestricted", + "outputs": [ + { + "internalType": "uint256", + "name": "auctionId", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "poke", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "rebalanceControl", + "outputs": [ + { + "internalType": "bool", + "name": "weightControl", + "type": "bool" + }, + { + "internalType": "enum IFolio.PriceControl", + "name": "priceControl", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + }, + { + "internalType": "address", + "name": "receiver", + "type": "address" + }, + { + "internalType": "address[]", + "name": "assets", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "minAmountsOut", + "type": "uint256[]" + } + ], + "name": "redeem", + "outputs": [ + { + "internalType": "uint256[]", + "name": "_amounts", + "type": "uint256[]" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "tokens", + "type": "address[]" + } + ], + "name": "removeFromAllowlist", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract IERC20", + "name": "token", + "type": "address" + } + ], + "name": "removeFromBasket", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "callerConfirmation", + "type": "address" + } + ], + "name": "renounceRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "revokeRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bool", + "name": "_bidsEnabled", + "type": "bool" + } + ], + "name": "setBidsEnabled", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint96", + "name": "portion", + "type": "uint96" + } + ], + "internalType": "struct IFolio.FeeRecipient[]", + "name": "_newRecipients", + "type": "tuple[]" + }, + { + "components": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint96", + "name": "portion", + "type": "uint96" + } + ], + "internalType": "struct IFolio.FeeRecipient[]", + "name": "_immutableRecipients", + "type": "tuple[]" + } + ], + "name": "setFeeRecipients", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_newFee", + "type": "uint256" + } + ], + "name": "setFolioSelfFee", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "_newMandate", + "type": "string" + } + ], + "name": "setMandate", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_newLength", + "type": "uint256" + } + ], + "name": "setMaxAuctionLength", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_newFee", + "type": "uint256" + } + ], + "name": "setMintFee", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "_newName", + "type": "string" + } + ], + "name": "setName", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "bool", + "name": "weightControl", + "type": "bool" + }, + { + "internalType": "enum IFolio.PriceControl", + "name": "priceControl", + "type": "uint8" + } + ], + "internalType": "struct IFolio.RebalanceControl", + "name": "_rebalanceControl", + "type": "tuple" + } + ], + "name": "setRebalanceControl", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_newFee", + "type": "uint256" + } + ], + "name": "setTVLFee", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bool", + "name": "_enabled", + "type": "bool" + } + ], + "name": "setTradeAllowlistEnabled", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_newFillerRegistry", + "type": "address" + }, + { + "internalType": "bool", + "name": "_enabled", + "type": "bool" + } + ], + "name": "setTrustedFillerRegistry", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "rebalanceNonce", + "type": "uint256" + }, + { + "components": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "low", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "spot", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "high", + "type": "uint256" + } + ], + "internalType": "struct IFolio.WeightRange", + "name": "weight", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "low", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "high", + "type": "uint256" + } + ], + "internalType": "struct IFolio.PriceRange", + "name": "price", + "type": "tuple" + }, + { + "internalType": "uint256", + "name": "maxAuctionSize", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "inRebalance", + "type": "bool" + } + ], + "internalType": "struct IFolio.TokenRebalanceParams[]", + "name": "tokens", + "type": "tuple[]" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "low", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "spot", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "high", + "type": "uint256" + } + ], + "internalType": "struct IFolio.RebalanceLimits", + "name": "limits", + "type": "tuple" + }, + { + "internalType": "uint256", + "name": "auctionLauncherWindow", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "ttl", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + } + ], + "name": "startRebalance", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "stateChangeActive", + "outputs": [ + { + "internalType": "bool", + "name": "syncStateChangeActive", + "type": "bool" + }, + { + "internalType": "bool", + "name": "asyncStateChangeActive", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "interfaceId", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + }, + { + "internalType": "enum Math.Rounding", + "name": "rounding", + "type": "uint8" + } + ], + "name": "toAssets", + "outputs": [ + { + "internalType": "address[]", + "name": "_assets", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "_amounts", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalAssets", + "outputs": [ + { + "internalType": "address[]", + "name": "_assets", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "_amounts", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "tradeAllowlistEnabled", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "trustedFillerEnabled", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "trustedFillerRegistry", + "outputs": [ + { + "internalType": "contract ITrustedFillerRegistry", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "tvlFee", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "version", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "pure", + "type": "function" + } + ] +} diff --git a/packages/sdk/src/index-dtf/abis/explorer/folio-deployer-6.0.0.base.json b/packages/sdk/src/index-dtf/abis/explorer/folio-deployer-6.0.0.base.json new file mode 100644 index 0000000..72094a9 --- /dev/null +++ b/packages/sdk/src/index-dtf/abis/explorer/folio-deployer-6.0.0.base.json @@ -0,0 +1,651 @@ +{ + "contract": "FolioDeployer", + "version": "6.0.0", + "chainId": 8453, + "address": "0x4c891fCa6319d492866672E3D2AfdAAA5bDcfF67", + "source": "https://base.blockscout.com/api/v2/smart-contracts/0x4c891fCa6319d492866672E3D2AfdAAA5bDcfF67", + "verifiedAt": "2026-09-30T02:14:54.911003Z", + "compiler": "0.8.28+commit.7893614a", + "sourceFile": "contracts/deployer/FolioDeployer.sol", + "runtimeCodeKeccak256": "0x25da310c34aa0a439b5616f5027e0b53dd8f0ef5ed539f66ea67e5eebad7d231", + "abi": [ + { + "inputs": [ + { + "internalType": "address", + "name": "_daoFeeRegistry", + "type": "address" + }, + { + "internalType": "address", + "name": "_versionRegistry", + "type": "address" + }, + { + "internalType": "address", + "name": "_trustedFillerRegistry", + "type": "address" + }, + { + "internalType": "address", + "name": "_optimisticGovernorDeployer", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "FolioDeployer__InvalidStToken", + "type": "error" + }, + { + "inputs": [], + "name": "FolioDeployer__LengthMismatch", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "SafeERC20FailedOperation", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "folioOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "folio", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "folioAdmin", + "type": "address" + } + ], + "name": "FolioDeployed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "stToken", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "folio", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "ownerGovernor", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "ownerTimelock", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "tradingGovernor", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "tradingTimelock", + "type": "address" + } + ], + "name": "GovernedFolioDeployed", + "type": "event" + }, + { + "inputs": [], + "name": "daoFeeRegistry", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "internalType": "string", + "name": "symbol", + "type": "string" + }, + { + "internalType": "address[]", + "name": "assets", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "amounts", + "type": "uint256[]" + }, + { + "internalType": "uint256", + "name": "initialShares", + "type": "uint256" + } + ], + "internalType": "struct IFolio.FolioBasicDetails", + "name": "basicDetails", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "maxAuctionLength", + "type": "uint256" + }, + { + "components": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint96", + "name": "portion", + "type": "uint96" + } + ], + "internalType": "struct IFolio.FeeRecipient[]", + "name": "feeRecipients", + "type": "tuple[]" + }, + { + "components": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint96", + "name": "portion", + "type": "uint96" + } + ], + "internalType": "struct IFolio.FeeRecipient[]", + "name": "immutableFeeRecipients", + "type": "tuple[]" + }, + { + "internalType": "uint256", + "name": "tvlFee", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "mintFee", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "folioFeeForSelf", + "type": "uint256" + }, + { + "internalType": "string", + "name": "mandate", + "type": "string" + } + ], + "internalType": "struct IFolio.FolioAdditionalDetails", + "name": "additionalDetails", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "bool", + "name": "trustedFillerEnabled", + "type": "bool" + }, + { + "components": [ + { + "internalType": "bool", + "name": "weightControl", + "type": "bool" + }, + { + "internalType": "enum IFolio.PriceControl", + "name": "priceControl", + "type": "uint8" + } + ], + "internalType": "struct IFolio.RebalanceControl", + "name": "rebalanceControl", + "type": "tuple" + }, + { + "internalType": "bool", + "name": "bidsEnabled", + "type": "bool" + } + ], + "internalType": "struct IFolio.FolioFlags", + "name": "folioFlags", + "type": "tuple" + }, + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address[]", + "name": "basketManagers", + "type": "address[]" + }, + { + "internalType": "address[]", + "name": "auctionLaunchers", + "type": "address[]" + }, + { + "internalType": "address[]", + "name": "brandManagers", + "type": "address[]" + }, + { + "internalType": "bytes32", + "name": "deploymentNonce", + "type": "bytes32" + } + ], + "name": "deployFolio", + "outputs": [ + { + "internalType": "contract Folio", + "name": "folio", + "type": "address" + }, + { + "internalType": "address", + "name": "proxyAdmin", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "stToken", + "type": "address" + }, + { + "components": [ + { + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "internalType": "string", + "name": "symbol", + "type": "string" + }, + { + "internalType": "address[]", + "name": "assets", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "amounts", + "type": "uint256[]" + }, + { + "internalType": "uint256", + "name": "initialShares", + "type": "uint256" + } + ], + "internalType": "struct IFolio.FolioBasicDetails", + "name": "basicDetails", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "maxAuctionLength", + "type": "uint256" + }, + { + "components": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint96", + "name": "portion", + "type": "uint96" + } + ], + "internalType": "struct IFolio.FeeRecipient[]", + "name": "feeRecipients", + "type": "tuple[]" + }, + { + "components": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint96", + "name": "portion", + "type": "uint96" + } + ], + "internalType": "struct IFolio.FeeRecipient[]", + "name": "immutableFeeRecipients", + "type": "tuple[]" + }, + { + "internalType": "uint256", + "name": "tvlFee", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "mintFee", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "folioFeeForSelf", + "type": "uint256" + }, + { + "internalType": "string", + "name": "mandate", + "type": "string" + } + ], + "internalType": "struct IFolio.FolioAdditionalDetails", + "name": "additionalDetails", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "bool", + "name": "trustedFillerEnabled", + "type": "bool" + }, + { + "components": [ + { + "internalType": "bool", + "name": "weightControl", + "type": "bool" + }, + { + "internalType": "enum IFolio.PriceControl", + "name": "priceControl", + "type": "uint8" + } + ], + "internalType": "struct IFolio.RebalanceControl", + "name": "rebalanceControl", + "type": "tuple" + }, + { + "internalType": "bool", + "name": "bidsEnabled", + "type": "bool" + } + ], + "internalType": "struct IFolio.FolioFlags", + "name": "folioFlags", + "type": "tuple" + }, + { + "components": [ + { + "components": [ + { + "internalType": "uint48", + "name": "vetoDelay", + "type": "uint48" + }, + { + "internalType": "uint32", + "name": "vetoPeriod", + "type": "uint32" + }, + { + "internalType": "uint256", + "name": "vetoThreshold", + "type": "uint256" + } + ], + "internalType": "struct IReserveOptimisticGovernor.OptimisticGovernanceParams", + "name": "optimisticParams", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint48", + "name": "votingDelay", + "type": "uint48" + }, + { + "internalType": "uint32", + "name": "votingPeriod", + "type": "uint32" + }, + { + "internalType": "uint48", + "name": "voteExtension", + "type": "uint48" + }, + { + "internalType": "uint256", + "name": "proposalThreshold", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "quorumNumerator", + "type": "uint256" + } + ], + "internalType": "struct IReserveOptimisticGovernor.StandardGovernanceParams", + "name": "standardParams", + "type": "tuple" + }, + { + "internalType": "bytes4[]", + "name": "optimisticSelectors", + "type": "bytes4[]" + }, + { + "internalType": "address[]", + "name": "optimisticProposers", + "type": "address[]" + }, + { + "internalType": "address[]", + "name": "additionalGuardians", + "type": "address[]" + }, + { + "internalType": "uint256", + "name": "timelockDelay", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "proposalThrottleCapacity", + "type": "uint256" + } + ], + "internalType": "struct IFolioDeployer.GovParams", + "name": "govParams", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "address[]", + "name": "existingBasketManagers", + "type": "address[]" + }, + { + "internalType": "address[]", + "name": "auctionLaunchers", + "type": "address[]" + }, + { + "internalType": "address[]", + "name": "brandManagers", + "type": "address[]" + } + ], + "internalType": "struct IFolioDeployer.GovRoles", + "name": "govRoles", + "type": "tuple" + }, + { + "internalType": "bytes32", + "name": "deploymentNonce", + "type": "bytes32" + } + ], + "name": "deployGovernedFolio", + "outputs": [ + { + "internalType": "contract Folio", + "name": "folio", + "type": "address" + }, + { + "internalType": "address", + "name": "proxyAdmin", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "folioImplementation", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "optimisticGovernorDeployer", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "trustedFillerRegistry", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "version", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [], + "name": "versionRegistry", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + } + ] +} diff --git a/packages/sdk/src/index-dtf/abis/folio-artifact.ts b/packages/sdk/src/index-dtf/abis/folio-artifact.ts index 667f94c..e768b6f 100644 --- a/packages/sdk/src/index-dtf/abis/folio-artifact.ts +++ b/packages/sdk/src/index-dtf/abis/folio-artifact.ts @@ -1,2818 +1,2 @@ -export const folioArtifact = { - contractName: "Folio", - abi: [ - { - type: "constructor", - inputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "DEFAULT_ADMIN_ROLE", - inputs: [], - outputs: [ - { - name: "", - type: "bytes32", - internalType: "bytes32", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "addToAllowlist", - inputs: [ - { - name: "tokens", - type: "address[]", - internalType: "address[]", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "addToBasket", - inputs: [ - { - name: "token", - type: "address", - internalType: "contract IERC20", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "allowance", - inputs: [ - { - name: "owner", - type: "address", - internalType: "address", - }, - { - name: "spender", - type: "address", - internalType: "address", - }, - ], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "approve", - inputs: [ - { - name: "spender", - type: "address", - internalType: "address", - }, - { - name: "value", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [ - { - name: "", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "auctions", - inputs: [ - { - name: "id", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [ - { - name: "rebalanceNonce", - type: "uint256", - internalType: "uint256", - }, - { - name: "startTime", - type: "uint256", - internalType: "uint256", - }, - { - name: "endTime", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "balanceOf", - inputs: [ - { - name: "account", - type: "address", - internalType: "address", - }, - ], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "bid", - inputs: [ - { - name: "auctionId", - type: "uint256", - internalType: "uint256", - }, - { - name: "sellToken", - type: "address", - internalType: "contract IERC20", - }, - { - name: "buyToken", - type: "address", - internalType: "contract IERC20", - }, - { - name: "sellAmount", - type: "uint256", - internalType: "uint256", - }, - { - name: "maxBuyAmount", - type: "uint256", - internalType: "uint256", - }, - { - name: "withCallback", - type: "bool", - internalType: "bool", - }, - { - name: "data", - type: "bytes", - internalType: "bytes", - }, - ], - outputs: [ - { - name: "boughtAmt", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "bidsEnabled", - inputs: [], - outputs: [ - { - name: "", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "closeAuction", - inputs: [ - { - name: "auctionId", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "createTrustedFill", - inputs: [ - { - name: "auctionId", - type: "uint256", - internalType: "uint256", - }, - { - name: "sellToken", - type: "address", - internalType: "contract IERC20", - }, - { - name: "buyToken", - type: "address", - internalType: "contract IERC20", - }, - { - name: "targetFiller", - type: "address", - internalType: "address", - }, - { - name: "deploymentSalt", - type: "bytes32", - internalType: "bytes32", - }, - ], - outputs: [ - { - name: "filler", - type: "address", - internalType: "contract IBaseTrustedFiller", - }, - ], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "daoFeeRegistry", - inputs: [], - outputs: [ - { - name: "", - type: "address", - internalType: "contract IFolioDAOFeeRegistry", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "daoPendingFeeShares", - inputs: [], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "decimals", - inputs: [], - outputs: [ - { - name: "", - type: "uint8", - internalType: "uint8", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "deprecateFolio", - inputs: [], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "distributeFees", - inputs: [], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "emergencyCloseTrustedFill", - inputs: [], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "endRebalance", - inputs: [], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "feeRecipients", - inputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [ - { - name: "recipient", - type: "address", - internalType: "address", - }, - { - name: "portion", - type: "uint96", - internalType: "uint96", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "feeRecipientsPendingFeeShares", - inputs: [], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "folioFeeForSelf", - inputs: [], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "getAuctionPrice", - inputs: [ - { - name: "auctionId", - type: "uint256", - internalType: "uint256", - }, - { - name: "token", - type: "address", - internalType: "address", - }, - ], - outputs: [ - { - name: "range", - type: "tuple", - internalType: "struct IFolio.PriceRange", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "getBid", - inputs: [ - { - name: "auctionId", - type: "uint256", - internalType: "uint256", - }, - { - name: "sellToken", - type: "address", - internalType: "contract IERC20", - }, - { - name: "buyToken", - type: "address", - internalType: "contract IERC20", - }, - { - name: "maxSellAmount", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [ - { - name: "sellAmount", - type: "uint256", - internalType: "uint256", - }, - { - name: "bidAmount", - type: "uint256", - internalType: "uint256", - }, - { - name: "price", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "getPendingFeeShares", - inputs: [], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "getRebalance", - inputs: [], - outputs: [ - { - name: "nonce", - type: "uint256", - internalType: "uint256", - }, - { - name: "priceControl", - type: "uint8", - internalType: "enum IFolio.PriceControl", - }, - { - name: "tokens", - type: "tuple[]", - internalType: "struct IFolio.TokenRebalanceParams[]", - components: [ - { - name: "token", - type: "address", - internalType: "address", - }, - { - name: "weight", - type: "tuple", - internalType: "struct IFolio.WeightRange", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "spot", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "price", - type: "tuple", - internalType: "struct IFolio.PriceRange", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "maxAuctionSize", - type: "uint256", - internalType: "uint256", - }, - { - name: "inRebalance", - type: "bool", - internalType: "bool", - }, - ], - }, - { - name: "limits", - type: "tuple", - internalType: "struct IFolio.RebalanceLimits", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "spot", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "timestamps", - type: "tuple", - internalType: "struct Folio.RebalanceTimestamps", - components: [ - { - name: "startedAt", - type: "uint256", - internalType: "uint256", - }, - { - name: "restrictedUntil", - type: "uint256", - internalType: "uint256", - }, - { - name: "availableUntil", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "bidsEnabled_", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "getRoleAdmin", - inputs: [ - { - name: "role", - type: "bytes32", - internalType: "bytes32", - }, - ], - outputs: [ - { - name: "", - type: "bytes32", - internalType: "bytes32", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "getRoleMember", - inputs: [ - { - name: "role", - type: "bytes32", - internalType: "bytes32", - }, - { - name: "index", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [ - { - name: "", - type: "address", - internalType: "address", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "getRoleMemberCount", - inputs: [ - { - name: "role", - type: "bytes32", - internalType: "bytes32", - }, - ], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "getRoleMembers", - inputs: [ - { - name: "role", - type: "bytes32", - internalType: "bytes32", - }, - ], - outputs: [ - { - name: "", - type: "address[]", - internalType: "address[]", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "getTokenAllowlist", - inputs: [], - outputs: [ - { - name: "", - type: "address[]", - internalType: "address[]", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "grantRole", - inputs: [ - { - name: "role", - type: "bytes32", - internalType: "bytes32", - }, - { - name: "account", - type: "address", - internalType: "address", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "hasRole", - inputs: [ - { - name: "role", - type: "bytes32", - internalType: "bytes32", - }, - { - name: "account", - type: "address", - internalType: "address", - }, - ], - outputs: [ - { - name: "", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "initialize", - inputs: [ - { - name: "_basicDetails", - type: "tuple", - internalType: "struct IFolio.FolioBasicDetails", - components: [ - { - name: "name", - type: "string", - internalType: "string", - }, - { - name: "symbol", - type: "string", - internalType: "string", - }, - { - name: "assets", - type: "address[]", - internalType: "address[]", - }, - { - name: "amounts", - type: "uint256[]", - internalType: "uint256[]", - }, - { - name: "initialShares", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "_additionalDetails", - type: "tuple", - internalType: "struct IFolio.FolioAdditionalDetails", - components: [ - { - name: "maxAuctionLength", - type: "uint256", - internalType: "uint256", - }, - { - name: "feeRecipients", - type: "tuple[]", - internalType: "struct IFolio.FeeRecipient[]", - components: [ - { - name: "recipient", - type: "address", - internalType: "address", - }, - { - name: "portion", - type: "uint96", - internalType: "uint96", - }, - ], - }, - { - name: "tvlFee", - type: "uint256", - internalType: "uint256", - }, - { - name: "mintFee", - type: "uint256", - internalType: "uint256", - }, - { - name: "folioFeeForSelf", - type: "uint256", - internalType: "uint256", - }, - { - name: "mandate", - type: "string", - internalType: "string", - }, - ], - }, - { - name: "_folioRegistries", - type: "tuple", - internalType: "struct IFolio.FolioRegistryIndex", - components: [ - { - name: "daoFeeRegistry", - type: "address", - internalType: "address", - }, - { - name: "trustedFillerRegistry", - type: "address", - internalType: "address", - }, - ], - }, - { - name: "_folioFlags", - type: "tuple", - internalType: "struct IFolio.FolioFlags", - components: [ - { - name: "trustedFillerEnabled", - type: "bool", - internalType: "bool", - }, - { - name: "rebalanceControl", - type: "tuple", - internalType: "struct IFolio.RebalanceControl", - components: [ - { - name: "weightControl", - type: "bool", - internalType: "bool", - }, - { - name: "priceControl", - type: "uint8", - internalType: "enum IFolio.PriceControl", - }, - ], - }, - { - name: "bidsEnabled", - type: "bool", - internalType: "bool", - }, - ], - }, - { - name: "_creator", - type: "address", - internalType: "address", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "isDeprecated", - inputs: [], - outputs: [ - { - name: "", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "isTokenAllowlisted", - inputs: [ - { - name: "token", - type: "address", - internalType: "address", - }, - ], - outputs: [ - { - name: "", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "lastPoke", - inputs: [], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "mandate", - inputs: [], - outputs: [ - { - name: "", - type: "string", - internalType: "string", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "maxAuctionLength", - inputs: [], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "mint", - inputs: [ - { - name: "shares", - type: "uint256", - internalType: "uint256", - }, - { - name: "receiver", - type: "address", - internalType: "address", - }, - { - name: "minSharesOut", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [ - { - name: "_assets", - type: "address[]", - internalType: "address[]", - }, - { - name: "_amounts", - type: "uint256[]", - internalType: "uint256[]", - }, - ], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "mintFee", - inputs: [], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "name", - inputs: [], - outputs: [ - { - name: "", - type: "string", - internalType: "string", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "nextAuctionId", - inputs: [], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "openAuction", - inputs: [ - { - name: "rebalanceNonce", - type: "uint256", - internalType: "uint256", - }, - { - name: "tokens", - type: "address[]", - internalType: "address[]", - }, - { - name: "newWeights", - type: "tuple[]", - internalType: "struct IFolio.WeightRange[]", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "spot", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "newPrices", - type: "tuple[]", - internalType: "struct IFolio.PriceRange[]", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "newLimits", - type: "tuple", - internalType: "struct IFolio.RebalanceLimits", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "spot", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "auctionLength", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [ - { - name: "auctionId", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "openAuctionUnrestricted", - inputs: [ - { - name: "rebalanceNonce", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [ - { - name: "auctionId", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "poke", - inputs: [], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "rebalanceControl", - inputs: [], - outputs: [ - { - name: "weightControl", - type: "bool", - internalType: "bool", - }, - { - name: "priceControl", - type: "uint8", - internalType: "enum IFolio.PriceControl", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "redeem", - inputs: [ - { - name: "shares", - type: "uint256", - internalType: "uint256", - }, - { - name: "receiver", - type: "address", - internalType: "address", - }, - { - name: "assets", - type: "address[]", - internalType: "address[]", - }, - { - name: "minAmountsOut", - type: "uint256[]", - internalType: "uint256[]", - }, - ], - outputs: [ - { - name: "_amounts", - type: "uint256[]", - internalType: "uint256[]", - }, - ], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "removeFromAllowlist", - inputs: [ - { - name: "tokens", - type: "address[]", - internalType: "address[]", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "removeFromBasket", - inputs: [ - { - name: "token", - type: "address", - internalType: "contract IERC20", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "renounceRole", - inputs: [ - { - name: "role", - type: "bytes32", - internalType: "bytes32", - }, - { - name: "callerConfirmation", - type: "address", - internalType: "address", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "revokeRole", - inputs: [ - { - name: "role", - type: "bytes32", - internalType: "bytes32", - }, - { - name: "account", - type: "address", - internalType: "address", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "setBidsEnabled", - inputs: [ - { - name: "_bidsEnabled", - type: "bool", - internalType: "bool", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "setFeeRecipients", - inputs: [ - { - name: "_newRecipients", - type: "tuple[]", - internalType: "struct IFolio.FeeRecipient[]", - components: [ - { - name: "recipient", - type: "address", - internalType: "address", - }, - { - name: "portion", - type: "uint96", - internalType: "uint96", - }, - ], - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "setFolioSelfFee", - inputs: [ - { - name: "_newFee", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "setMandate", - inputs: [ - { - name: "_newMandate", - type: "string", - internalType: "string", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "setMaxAuctionLength", - inputs: [ - { - name: "_newLength", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "setMintFee", - inputs: [ - { - name: "_newFee", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "setName", - inputs: [ - { - name: "_newName", - type: "string", - internalType: "string", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "setRebalanceControl", - inputs: [ - { - name: "_rebalanceControl", - type: "tuple", - internalType: "struct IFolio.RebalanceControl", - components: [ - { - name: "weightControl", - type: "bool", - internalType: "bool", - }, - { - name: "priceControl", - type: "uint8", - internalType: "enum IFolio.PriceControl", - }, - ], - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "setTVLFee", - inputs: [ - { - name: "_newFee", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "setTradeAllowlistEnabled", - inputs: [ - { - name: "_enabled", - type: "bool", - internalType: "bool", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "setTrustedFillerRegistry", - inputs: [ - { - name: "_newFillerRegistry", - type: "address", - internalType: "address", - }, - { - name: "_enabled", - type: "bool", - internalType: "bool", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "startRebalance", - inputs: [ - { - name: "tokens", - type: "tuple[]", - internalType: "struct IFolio.TokenRebalanceParams[]", - components: [ - { - name: "token", - type: "address", - internalType: "address", - }, - { - name: "weight", - type: "tuple", - internalType: "struct IFolio.WeightRange", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "spot", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "price", - type: "tuple", - internalType: "struct IFolio.PriceRange", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "maxAuctionSize", - type: "uint256", - internalType: "uint256", - }, - { - name: "inRebalance", - type: "bool", - internalType: "bool", - }, - ], - }, - { - name: "limits", - type: "tuple", - internalType: "struct IFolio.RebalanceLimits", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "spot", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "auctionLauncherWindow", - type: "uint256", - internalType: "uint256", - }, - { - name: "ttl", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "stateChangeActive", - inputs: [], - outputs: [ - { - name: "syncStateChangeActive", - type: "bool", - internalType: "bool", - }, - { - name: "asyncStateChangeActive", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "supportsInterface", - inputs: [ - { - name: "interfaceId", - type: "bytes4", - internalType: "bytes4", - }, - ], - outputs: [ - { - name: "", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "symbol", - inputs: [], - outputs: [ - { - name: "", - type: "string", - internalType: "string", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "toAssets", - inputs: [ - { - name: "shares", - type: "uint256", - internalType: "uint256", - }, - { - name: "rounding", - type: "uint8", - internalType: "enum Math.Rounding", - }, - ], - outputs: [ - { - name: "_assets", - type: "address[]", - internalType: "address[]", - }, - { - name: "_amounts", - type: "uint256[]", - internalType: "uint256[]", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "totalAssets", - inputs: [], - outputs: [ - { - name: "_assets", - type: "address[]", - internalType: "address[]", - }, - { - name: "_amounts", - type: "uint256[]", - internalType: "uint256[]", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "totalSupply", - inputs: [], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "tradeAllowlistEnabled", - inputs: [], - outputs: [ - { - name: "", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "transfer", - inputs: [ - { - name: "to", - type: "address", - internalType: "address", - }, - { - name: "value", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [ - { - name: "", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "transferFrom", - inputs: [ - { - name: "from", - type: "address", - internalType: "address", - }, - { - name: "to", - type: "address", - internalType: "address", - }, - { - name: "value", - type: "uint256", - internalType: "uint256", - }, - ], - outputs: [ - { - name: "", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "trustedFillerEnabled", - inputs: [], - outputs: [ - { - name: "", - type: "bool", - internalType: "bool", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "trustedFillerRegistry", - inputs: [], - outputs: [ - { - name: "", - type: "address", - internalType: "contract ITrustedFillerRegistry", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "tvlFee", - inputs: [], - outputs: [ - { - name: "", - type: "uint256", - internalType: "uint256", - }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "version", - inputs: [], - outputs: [ - { - name: "", - type: "string", - internalType: "string", - }, - ], - stateMutability: "pure", - }, - { - type: "event", - name: "Approval", - inputs: [ - { - name: "owner", - type: "address", - indexed: true, - internalType: "address", - }, - { - name: "spender", - type: "address", - indexed: true, - internalType: "address", - }, - { - name: "value", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "AuctionBid", - inputs: [ - { - name: "auctionId", - type: "uint256", - indexed: true, - internalType: "uint256", - }, - { - name: "sellToken", - type: "address", - indexed: true, - internalType: "address", - }, - { - name: "buyToken", - type: "address", - indexed: true, - internalType: "address", - }, - { - name: "sellAmount", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - { - name: "buyAmount", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "AuctionClosed", - inputs: [ - { - name: "auctionId", - type: "uint256", - indexed: true, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "AuctionOpened", - inputs: [ - { - name: "rebalanceNonce", - type: "uint256", - indexed: true, - internalType: "uint256", - }, - { - name: "auctionId", - type: "uint256", - indexed: true, - internalType: "uint256", - }, - { - name: "tokens", - type: "address[]", - indexed: false, - internalType: "address[]", - }, - { - name: "weights", - type: "tuple[]", - indexed: false, - internalType: "struct IFolio.WeightRange[]", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "spot", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "prices", - type: "tuple[]", - indexed: false, - internalType: "struct IFolio.PriceRange[]", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "limits", - type: "tuple", - indexed: false, - internalType: "struct IFolio.RebalanceLimits", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "spot", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "startTime", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - { - name: "endTime", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "AuctionTrustedFillCreated", - inputs: [ - { - name: "auctionId", - type: "uint256", - indexed: true, - internalType: "uint256", - }, - { - name: "filler", - type: "address", - indexed: false, - internalType: "address", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "BasketTokenAdded", - inputs: [ - { - name: "token", - type: "address", - indexed: true, - internalType: "address", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "BasketTokenRemoved", - inputs: [ - { - name: "token", - type: "address", - indexed: true, - internalType: "address", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "BidsEnabledSet", - inputs: [ - { - name: "bidsEnabled", - type: "bool", - indexed: false, - internalType: "bool", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "FeeRecipientsSet", - inputs: [ - { - name: "recipients", - type: "tuple[]", - indexed: false, - internalType: "struct IFolio.FeeRecipient[]", - components: [ - { - name: "recipient", - type: "address", - internalType: "address", - }, - { - name: "portion", - type: "uint96", - internalType: "uint96", - }, - ], - }, - ], - anonymous: false, - }, - { - type: "event", - name: "FolioDeprecated", - inputs: [], - anonymous: false, - }, - { - type: "event", - name: "FolioFeePaid", - inputs: [ - { - name: "recipient", - type: "address", - indexed: true, - internalType: "address", - }, - { - name: "amount", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "FolioFeeSet", - inputs: [ - { - name: "newFolioFee", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "Initialized", - inputs: [ - { - name: "version", - type: "uint64", - indexed: false, - internalType: "uint64", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "MandateSet", - inputs: [ - { - name: "newMandate", - type: "string", - indexed: false, - internalType: "string", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "MaxAuctionLengthSet", - inputs: [ - { - name: "newMaxAuctionLength", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "MintFeeSet", - inputs: [ - { - name: "newFee", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "NameSet", - inputs: [ - { - name: "newName", - type: "string", - indexed: false, - internalType: "string", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "ProtocolFeePaid", - inputs: [ - { - name: "recipient", - type: "address", - indexed: true, - internalType: "address", - }, - { - name: "amount", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "RebalanceControlSet", - inputs: [ - { - name: "newControl", - type: "tuple", - indexed: false, - internalType: "struct IFolio.RebalanceControl", - components: [ - { - name: "weightControl", - type: "bool", - internalType: "bool", - }, - { - name: "priceControl", - type: "uint8", - internalType: "enum IFolio.PriceControl", - }, - ], - }, - ], - anonymous: false, - }, - { - type: "event", - name: "RebalanceEnded", - inputs: [ - { - name: "nonce", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "RebalanceStarted", - inputs: [ - { - name: "nonce", - type: "uint256", - indexed: true, - internalType: "uint256", - }, - { - name: "priceControl", - type: "uint8", - indexed: false, - internalType: "enum IFolio.PriceControl", - }, - { - name: "tokens", - type: "tuple[]", - indexed: false, - internalType: "struct IFolio.TokenRebalanceParams[]", - components: [ - { - name: "token", - type: "address", - internalType: "address", - }, - { - name: "weight", - type: "tuple", - internalType: "struct IFolio.WeightRange", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "spot", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "price", - type: "tuple", - internalType: "struct IFolio.PriceRange", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "maxAuctionSize", - type: "uint256", - internalType: "uint256", - }, - { - name: "inRebalance", - type: "bool", - internalType: "bool", - }, - ], - }, - { - name: "limits", - type: "tuple", - indexed: false, - internalType: "struct IFolio.RebalanceLimits", - components: [ - { - name: "low", - type: "uint256", - internalType: "uint256", - }, - { - name: "spot", - type: "uint256", - internalType: "uint256", - }, - { - name: "high", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - name: "startedAt", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - { - name: "restrictedUntil", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - { - name: "availableUntil", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - { - name: "bidsEnabled", - type: "bool", - indexed: false, - internalType: "bool", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "RoleAdminChanged", - inputs: [ - { - name: "role", - type: "bytes32", - indexed: true, - internalType: "bytes32", - }, - { - name: "previousAdminRole", - type: "bytes32", - indexed: true, - internalType: "bytes32", - }, - { - name: "newAdminRole", - type: "bytes32", - indexed: true, - internalType: "bytes32", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "RoleGranted", - inputs: [ - { - name: "role", - type: "bytes32", - indexed: true, - internalType: "bytes32", - }, - { - name: "account", - type: "address", - indexed: true, - internalType: "address", - }, - { - name: "sender", - type: "address", - indexed: true, - internalType: "address", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "RoleRevoked", - inputs: [ - { - name: "role", - type: "bytes32", - indexed: true, - internalType: "bytes32", - }, - { - name: "account", - type: "address", - indexed: true, - internalType: "address", - }, - { - name: "sender", - type: "address", - indexed: true, - internalType: "address", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "TVLFeeSet", - inputs: [ - { - name: "newFee", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - { - name: "feeAnnually", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "TradeAllowlistEnabled", - inputs: [ - { - name: "enabled", - type: "bool", - indexed: false, - internalType: "bool", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "TradeAllowlistTokenAdded", - inputs: [ - { - name: "token", - type: "address", - indexed: true, - internalType: "address", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "TradeAllowlistTokenRemoved", - inputs: [ - { - name: "token", - type: "address", - indexed: true, - internalType: "address", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "Transfer", - inputs: [ - { - name: "from", - type: "address", - indexed: true, - internalType: "address", - }, - { - name: "to", - type: "address", - indexed: true, - internalType: "address", - }, - { - name: "value", - type: "uint256", - indexed: false, - internalType: "uint256", - }, - ], - anonymous: false, - }, - { - type: "event", - name: "TrustedFillerRegistrySet", - inputs: [ - { - name: "trustedFillerRegistry", - type: "address", - indexed: false, - internalType: "address", - }, - { - name: "isEnabled", - type: "bool", - indexed: false, - internalType: "bool", - }, - ], - anonymous: false, - }, - { - type: "error", - name: "AccessControlBadConfirmation", - inputs: [], - }, - { - type: "error", - name: "AccessControlUnauthorizedAccount", - inputs: [ - { - name: "account", - type: "address", - internalType: "address", - }, - { - name: "neededRole", - type: "bytes32", - internalType: "bytes32", - }, - ], - }, - { - type: "error", - name: "ERC20InsufficientAllowance", - inputs: [ - { - name: "spender", - type: "address", - internalType: "address", - }, - { - name: "allowance", - type: "uint256", - internalType: "uint256", - }, - { - name: "needed", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - type: "error", - name: "ERC20InsufficientBalance", - inputs: [ - { - name: "sender", - type: "address", - internalType: "address", - }, - { - name: "balance", - type: "uint256", - internalType: "uint256", - }, - { - name: "needed", - type: "uint256", - internalType: "uint256", - }, - ], - }, - { - type: "error", - name: "ERC20InvalidApprover", - inputs: [ - { - name: "approver", - type: "address", - internalType: "address", - }, - ], - }, - { - type: "error", - name: "ERC20InvalidReceiver", - inputs: [ - { - name: "receiver", - type: "address", - internalType: "address", - }, - ], - }, - { - type: "error", - name: "ERC20InvalidSender", - inputs: [ - { - name: "sender", - type: "address", - internalType: "address", - }, - ], - }, - { - type: "error", - name: "ERC20InvalidSpender", - inputs: [ - { - name: "spender", - type: "address", - internalType: "address", - }, - ], - }, - { - type: "error", - name: "Folio__AuctionCannotBeOpenedWithoutRestriction", - inputs: [], - }, - { - type: "error", - name: "Folio__AuctionNotOngoing", - inputs: [], - }, - { - type: "error", - name: "Folio__BadFeeTotal", - inputs: [], - }, - { - type: "error", - name: "Folio__BalanceNotRemovable", - inputs: [], - }, - { - type: "error", - name: "Folio__BasketModificationFailed", - inputs: [], - }, - { - type: "error", - name: "Folio__DuplicateAsset", - inputs: [], - }, - { - type: "error", - name: "Folio__EmptyAssets", - inputs: [], - }, - { - type: "error", - name: "Folio__EmptyRebalance", - inputs: [], - }, - { - type: "error", - name: "Folio__FeeRecipientInvalidAddress", - inputs: [], - }, - { - type: "error", - name: "Folio__FeeRecipientInvalidFeeShare", - inputs: [], - }, - { - type: "error", - name: "Folio__FolioDeprecated", - inputs: [], - }, - { - type: "error", - name: "Folio__FolioFeeTooHigh", - inputs: [], - }, - { - type: "error", - name: "Folio__InsufficientBid", - inputs: [], - }, - { - type: "error", - name: "Folio__InsufficientBuyAvailable", - inputs: [], - }, - { - type: "error", - name: "Folio__InsufficientSellAvailable", - inputs: [], - }, - { - type: "error", - name: "Folio__InsufficientSharesOut", - inputs: [], - }, - { - type: "error", - name: "Folio__InvalidArrayLengths", - inputs: [], - }, - { - type: "error", - name: "Folio__InvalidAsset", - inputs: [], - }, - { - type: "error", - name: "Folio__InvalidAssetAmount", - inputs: [ - { - name: "asset", - type: "address", - internalType: "address", - }, - ], - }, - { - type: "error", - name: "Folio__InvalidAuctionLength", - inputs: [], - }, - { - type: "error", - name: "Folio__InvalidLimits", - inputs: [], - }, - { - type: "error", - name: "Folio__InvalidPrices", - inputs: [], - }, - { - type: "error", - name: "Folio__InvalidRegistry", - inputs: [], - }, - { - type: "error", - name: "Folio__InvalidTTL", - inputs: [], - }, - { - type: "error", - name: "Folio__InvalidTransferToSelf", - inputs: [], - }, - { - type: "error", - name: "Folio__InvalidWeights", - inputs: [], - }, - { - type: "error", - name: "Folio__MintFeeTooHigh", - inputs: [], - }, - { - type: "error", - name: "Folio__MixedAtomicSwaps", - inputs: [], - }, - { - type: "error", - name: "Folio__NotRebalancing", - inputs: [], - }, - { - type: "error", - name: "Folio__PermissionlessBidsDisabled", - inputs: [], - }, - { - type: "error", - name: "Folio__SlippageExceeded", - inputs: [], - }, - { - type: "error", - name: "Folio__TVLFeeTooHigh", - inputs: [], - }, - { - type: "error", - name: "Folio__TVLFeeTooLow", - inputs: [], - }, - { - type: "error", - name: "Folio__TokenNotAllowlisted", - inputs: [], - }, - { - type: "error", - name: "Folio__TooManyFeeRecipients", - inputs: [], - }, - { - type: "error", - name: "Folio__TrustedFillerRegistryAlreadySet", - inputs: [], - }, - { - type: "error", - name: "Folio__TrustedFillerRegistryNotEnabled", - inputs: [], - }, - { - type: "error", - name: "Folio__Unauthorized", - inputs: [], - }, - { - type: "error", - name: "Folio__ZeroInitialShares", - inputs: [], - }, - { - type: "error", - name: "Folo__NotInRebalance", - inputs: [], - }, - { - type: "error", - name: "InvalidInitialization", - inputs: [], - }, - { - type: "error", - name: "NotInitializing", - inputs: [], - }, - { - type: "error", - name: "ReentrancyGuardReentrantCall", - inputs: [], - }, - { - type: "error", - name: "SafeERC20FailedOperation", - inputs: [ - { - name: "token", - type: "address", - internalType: "address", - }, - ], - }, - ], -} as const; -export const folioArtifactAbi = folioArtifact.abi; -export default folioArtifactAbi; +// Compatibility alias. The generated Folio v6 ABI is the canonical source. +export { folioV6Abi as folioArtifactAbi } from "@/index-dtf/abis/folio-v6.generated"; diff --git a/packages/sdk/src/index-dtf/abis/folio-deployer-v6.generated.ts b/packages/sdk/src/index-dtf/abis/folio-deployer-v6.generated.ts new file mode 100644 index 0000000..dfedf46 --- /dev/null +++ b/packages/sdk/src/index-dtf/abis/folio-deployer-v6.generated.ts @@ -0,0 +1,644 @@ +// Generated by packages/sdk/scripts/sync-index-dtf-v6-abis.mjs. +// Source: reserve-protocol/reserve-index-dtf@7d97c807003c563038ec948e92eb43eabba43ef0 +// Artifact: out/FolioDeployer.sol/FolioDeployer.json +// Do not edit by hand; run `pnpm abi:index-v6:sync`. +export const folioDeployerV6Abi = [ + { + "type": "constructor", + "inputs": [ + { + "name": "_daoFeeRegistry", + "type": "address", + "internalType": "address" + }, + { + "name": "_versionRegistry", + "type": "address", + "internalType": "address" + }, + { + "name": "_trustedFillerRegistry", + "type": "address", + "internalType": "address" + }, + { + "name": "_optimisticGovernorDeployer", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "daoFeeRegistry", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "deployFolio", + "inputs": [ + { + "name": "basicDetails", + "type": "tuple", + "internalType": "struct IFolio.FolioBasicDetails", + "components": [ + { + "name": "name", + "type": "string", + "internalType": "string" + }, + { + "name": "symbol", + "type": "string", + "internalType": "string" + }, + { + "name": "assets", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "amounts", + "type": "uint256[]", + "internalType": "uint256[]" + }, + { + "name": "initialShares", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "additionalDetails", + "type": "tuple", + "internalType": "struct IFolio.FolioAdditionalDetails", + "components": [ + { + "name": "maxAuctionLength", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "feeRecipients", + "type": "tuple[]", + "internalType": "struct IFolio.FeeRecipient[]", + "components": [ + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "portion", + "type": "uint96", + "internalType": "uint96" + } + ] + }, + { + "name": "immutableFeeRecipients", + "type": "tuple[]", + "internalType": "struct IFolio.FeeRecipient[]", + "components": [ + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "portion", + "type": "uint96", + "internalType": "uint96" + } + ] + }, + { + "name": "tvlFee", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "mintFee", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "folioFeeForSelf", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "mandate", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "folioFlags", + "type": "tuple", + "internalType": "struct IFolio.FolioFlags", + "components": [ + { + "name": "trustedFillerEnabled", + "type": "bool", + "internalType": "bool" + }, + { + "name": "rebalanceControl", + "type": "tuple", + "internalType": "struct IFolio.RebalanceControl", + "components": [ + { + "name": "weightControl", + "type": "bool", + "internalType": "bool" + }, + { + "name": "priceControl", + "type": "uint8", + "internalType": "enum IFolio.PriceControl" + } + ] + }, + { + "name": "bidsEnabled", + "type": "bool", + "internalType": "bool" + } + ] + }, + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "basketManagers", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "auctionLaunchers", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "brandManagers", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "deploymentNonce", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "folio", + "type": "address", + "internalType": "contract Folio" + }, + { + "name": "proxyAdmin", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "deployGovernedFolio", + "inputs": [ + { + "name": "stToken", + "type": "address", + "internalType": "address" + }, + { + "name": "basicDetails", + "type": "tuple", + "internalType": "struct IFolio.FolioBasicDetails", + "components": [ + { + "name": "name", + "type": "string", + "internalType": "string" + }, + { + "name": "symbol", + "type": "string", + "internalType": "string" + }, + { + "name": "assets", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "amounts", + "type": "uint256[]", + "internalType": "uint256[]" + }, + { + "name": "initialShares", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "additionalDetails", + "type": "tuple", + "internalType": "struct IFolio.FolioAdditionalDetails", + "components": [ + { + "name": "maxAuctionLength", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "feeRecipients", + "type": "tuple[]", + "internalType": "struct IFolio.FeeRecipient[]", + "components": [ + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "portion", + "type": "uint96", + "internalType": "uint96" + } + ] + }, + { + "name": "immutableFeeRecipients", + "type": "tuple[]", + "internalType": "struct IFolio.FeeRecipient[]", + "components": [ + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "portion", + "type": "uint96", + "internalType": "uint96" + } + ] + }, + { + "name": "tvlFee", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "mintFee", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "folioFeeForSelf", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "mandate", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "folioFlags", + "type": "tuple", + "internalType": "struct IFolio.FolioFlags", + "components": [ + { + "name": "trustedFillerEnabled", + "type": "bool", + "internalType": "bool" + }, + { + "name": "rebalanceControl", + "type": "tuple", + "internalType": "struct IFolio.RebalanceControl", + "components": [ + { + "name": "weightControl", + "type": "bool", + "internalType": "bool" + }, + { + "name": "priceControl", + "type": "uint8", + "internalType": "enum IFolio.PriceControl" + } + ] + }, + { + "name": "bidsEnabled", + "type": "bool", + "internalType": "bool" + } + ] + }, + { + "name": "govParams", + "type": "tuple", + "internalType": "struct IFolioDeployer.GovParams", + "components": [ + { + "name": "optimisticParams", + "type": "tuple", + "internalType": "struct IReserveOptimisticGovernor.OptimisticGovernanceParams", + "components": [ + { + "name": "vetoDelay", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "vetoPeriod", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "vetoThreshold", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "standardParams", + "type": "tuple", + "internalType": "struct IReserveOptimisticGovernor.StandardGovernanceParams", + "components": [ + { + "name": "votingDelay", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "votingPeriod", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "voteExtension", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "proposalThreshold", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "quorumNumerator", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "optimisticSelectors", + "type": "bytes4[]", + "internalType": "bytes4[]" + }, + { + "name": "optimisticProposers", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "additionalGuardians", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "timelockDelay", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "proposalThrottleCapacity", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "govRoles", + "type": "tuple", + "internalType": "struct IFolioDeployer.GovRoles", + "components": [ + { + "name": "existingBasketManagers", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "auctionLaunchers", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "brandManagers", + "type": "address[]", + "internalType": "address[]" + } + ] + }, + { + "name": "deploymentNonce", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "folio", + "type": "address", + "internalType": "contract Folio" + }, + { + "name": "proxyAdmin", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "folioImplementation", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "optimisticGovernorDeployer", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "trustedFillerRegistry", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "version", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "versionRegistry", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "FolioDeployed", + "inputs": [ + { + "name": "folioOwner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "folio", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "folioAdmin", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "GovernedFolioDeployed", + "inputs": [ + { + "name": "stToken", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "folio", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "ownerGovernor", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "ownerTimelock", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "tradingGovernor", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "tradingTimelock", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "FolioDeployer__InvalidStToken", + "inputs": [] + }, + { + "type": "error", + "name": "FolioDeployer__LengthMismatch", + "inputs": [] + }, + { + "type": "error", + "name": "SafeERC20FailedOperation", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ] + } +] as const; diff --git a/packages/sdk/src/index-dtf/abis/folio-v6.generated.ts b/packages/sdk/src/index-dtf/abis/folio-v6.generated.ts new file mode 100644 index 0000000..518f584 --- /dev/null +++ b/packages/sdk/src/index-dtf/abis/folio-v6.generated.ts @@ -0,0 +1,2971 @@ +// Generated by packages/sdk/scripts/sync-index-dtf-v6-abis.mjs. +// Source: reserve-protocol/reserve-index-dtf@7d97c807003c563038ec948e92eb43eabba43ef0 +// Artifact: out/Folio.sol/Folio.json +// Do not edit by hand; run `pnpm abi:index-v6:sync`. +export const folioV6Abi = [ + { + "type": "constructor", + "inputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "DEFAULT_ADMIN_ROLE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "addToAllowlist", + "inputs": [ + { + "name": "tokens", + "type": "address[]", + "internalType": "address[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "addToBasket", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "contract IERC20" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "allowance", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "spender", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "approve", + "inputs": [ + { + "name": "spender", + "type": "address", + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "auctions", + "inputs": [ + { + "name": "id", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "rebalanceNonce", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "startTime", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "endTime", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "balanceOf", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "bid", + "inputs": [ + { + "name": "auctionId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "sellToken", + "type": "address", + "internalType": "contract IERC20" + }, + { + "name": "buyToken", + "type": "address", + "internalType": "contract IERC20" + }, + { + "name": "sellAmount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "maxBuyAmount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "withCallback", + "type": "bool", + "internalType": "bool" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "boughtAmt", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "bidsEnabled", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "closeAuction", + "inputs": [ + { + "name": "auctionId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "createTrustedFill", + "inputs": [ + { + "name": "auctionId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "sellToken", + "type": "address", + "internalType": "contract IERC20" + }, + { + "name": "buyToken", + "type": "address", + "internalType": "contract IERC20" + }, + { + "name": "targetFiller", + "type": "address", + "internalType": "address" + }, + { + "name": "deploymentSalt", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "filler", + "type": "address", + "internalType": "contract IBaseTrustedFiller" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "daoFeeRegistry", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IFolioDAOFeeRegistry" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "daoPendingFeeShares", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "decimals", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint8", + "internalType": "uint8" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "deprecateFolio", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "distributeFees", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "emergencyCloseTrustedFill", + "inputs": [ + { + "name": "trustedFill", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "endRebalance", + "inputs": [ + { + "name": "rebalanceNonce", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "feeRecipients", + "inputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "portion", + "type": "uint96", + "internalType": "uint96" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "feeRecipientsPendingFeeShares", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "folioFeeForSelf", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "folioPendingMintFeeShares", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getAuctionPrice", + "inputs": [ + { + "name": "auctionId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "token", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "range", + "type": "tuple", + "internalType": "struct IFolio.PriceRange", + "components": [ + { + "name": "low", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "high", + "type": "uint256", + "internalType": "uint256" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getBid", + "inputs": [ + { + "name": "auctionId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "sellToken", + "type": "address", + "internalType": "contract IERC20" + }, + { + "name": "buyToken", + "type": "address", + "internalType": "contract IERC20" + }, + { + "name": "maxSellAmount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "sellAmount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "bidAmount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "price", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getPendingFeeShares", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getRebalance", + "inputs": [], + "outputs": [ + { + "name": "nonce", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "priceControl", + "type": "uint8", + "internalType": "enum IFolio.PriceControl" + }, + { + "name": "tokens", + "type": "tuple[]", + "internalType": "struct IFolio.TokenRebalanceParams[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "weight", + "type": "tuple", + "internalType": "struct IFolio.WeightRange", + "components": [ + { + "name": "low", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "spot", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "high", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "price", + "type": "tuple", + "internalType": "struct IFolio.PriceRange", + "components": [ + { + "name": "low", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "high", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "maxAuctionSize", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "inRebalance", + "type": "bool", + "internalType": "bool" + } + ] + }, + { + "name": "limits", + "type": "tuple", + "internalType": "struct IFolio.RebalanceLimits", + "components": [ + { + "name": "low", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "spot", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "high", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "timestamps", + "type": "tuple", + "internalType": "struct Folio.RebalanceTimestamps", + "components": [ + { + "name": "startedAt", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "restrictedUntil", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "availableUntil", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "bidsEnabled_", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getRebalanceNonce", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getRoleAdmin", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getRoleMember", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getRoleMemberCount", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getRoleMembers", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "address[]", + "internalType": "address[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getTokenAllowlist", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address[]", + "internalType": "address[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "grantRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "hasRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "immutableFeeRecipients", + "inputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "portion", + "type": "uint96", + "internalType": "uint96" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "_basicDetails", + "type": "tuple", + "internalType": "struct IFolio.FolioBasicDetails", + "components": [ + { + "name": "name", + "type": "string", + "internalType": "string" + }, + { + "name": "symbol", + "type": "string", + "internalType": "string" + }, + { + "name": "assets", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "amounts", + "type": "uint256[]", + "internalType": "uint256[]" + }, + { + "name": "initialShares", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "_additionalDetails", + "type": "tuple", + "internalType": "struct IFolio.FolioAdditionalDetails", + "components": [ + { + "name": "maxAuctionLength", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "feeRecipients", + "type": "tuple[]", + "internalType": "struct IFolio.FeeRecipient[]", + "components": [ + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "portion", + "type": "uint96", + "internalType": "uint96" + } + ] + }, + { + "name": "immutableFeeRecipients", + "type": "tuple[]", + "internalType": "struct IFolio.FeeRecipient[]", + "components": [ + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "portion", + "type": "uint96", + "internalType": "uint96" + } + ] + }, + { + "name": "tvlFee", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "mintFee", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "folioFeeForSelf", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "mandate", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "_folioRegistries", + "type": "tuple", + "internalType": "struct IFolio.FolioRegistryIndex", + "components": [ + { + "name": "daoFeeRegistry", + "type": "address", + "internalType": "address" + }, + { + "name": "trustedFillerRegistry", + "type": "address", + "internalType": "address" + } + ] + }, + { + "name": "_folioFlags", + "type": "tuple", + "internalType": "struct IFolio.FolioFlags", + "components": [ + { + "name": "trustedFillerEnabled", + "type": "bool", + "internalType": "bool" + }, + { + "name": "rebalanceControl", + "type": "tuple", + "internalType": "struct IFolio.RebalanceControl", + "components": [ + { + "name": "weightControl", + "type": "bool", + "internalType": "bool" + }, + { + "name": "priceControl", + "type": "uint8", + "internalType": "enum IFolio.PriceControl" + } + ] + }, + { + "name": "bidsEnabled", + "type": "bool", + "internalType": "bool" + } + ] + }, + { + "name": "_creator", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "isDeprecated", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isTokenAllowlisted", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "lastFolioFeePoke", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "lastPoke", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "mandate", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "maxAuctionLength", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "mint", + "inputs": [ + { + "name": "shares", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "receiver", + "type": "address", + "internalType": "address" + }, + { + "name": "minSharesOut", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "_assets", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "_amounts", + "type": "uint256[]", + "internalType": "uint256[]" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "mintFee", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "name", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "nextAuctionId", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "openAuction", + "inputs": [ + { + "name": "rebalanceNonce", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "tokens", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "newWeights", + "type": "tuple[]", + "internalType": "struct IFolio.WeightRange[]", + "components": [ + { + "name": "low", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "spot", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "high", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "newPrices", + "type": "tuple[]", + "internalType": "struct IFolio.PriceRange[]", + "components": [ + { + "name": "low", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "high", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "newLimits", + "type": "tuple", + "internalType": "struct IFolio.RebalanceLimits", + "components": [ + { + "name": "low", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "spot", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "high", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "auctionLength", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "auctionId", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "openAuctionUnrestricted", + "inputs": [ + { + "name": "rebalanceNonce", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "auctionId", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "poke", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "rebalanceControl", + "inputs": [], + "outputs": [ + { + "name": "weightControl", + "type": "bool", + "internalType": "bool" + }, + { + "name": "priceControl", + "type": "uint8", + "internalType": "enum IFolio.PriceControl" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "redeem", + "inputs": [ + { + "name": "shares", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "receiver", + "type": "address", + "internalType": "address" + }, + { + "name": "assets", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "minAmountsOut", + "type": "uint256[]", + "internalType": "uint256[]" + } + ], + "outputs": [ + { + "name": "_amounts", + "type": "uint256[]", + "internalType": "uint256[]" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "removeFromAllowlist", + "inputs": [ + { + "name": "tokens", + "type": "address[]", + "internalType": "address[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "removeFromBasket", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "contract IERC20" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "renounceRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "callerConfirmation", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "revokeRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setBidsEnabled", + "inputs": [ + { + "name": "_bidsEnabled", + "type": "bool", + "internalType": "bool" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setFeeRecipients", + "inputs": [ + { + "name": "_newRecipients", + "type": "tuple[]", + "internalType": "struct IFolio.FeeRecipient[]", + "components": [ + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "portion", + "type": "uint96", + "internalType": "uint96" + } + ] + }, + { + "name": "_immutableRecipients", + "type": "tuple[]", + "internalType": "struct IFolio.FeeRecipient[]", + "components": [ + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "portion", + "type": "uint96", + "internalType": "uint96" + } + ] + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setFolioSelfFee", + "inputs": [ + { + "name": "_newFee", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setMandate", + "inputs": [ + { + "name": "_newMandate", + "type": "string", + "internalType": "string" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setMaxAuctionLength", + "inputs": [ + { + "name": "_newLength", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setMintFee", + "inputs": [ + { + "name": "_newFee", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setName", + "inputs": [ + { + "name": "_newName", + "type": "string", + "internalType": "string" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setRebalanceControl", + "inputs": [ + { + "name": "_rebalanceControl", + "type": "tuple", + "internalType": "struct IFolio.RebalanceControl", + "components": [ + { + "name": "weightControl", + "type": "bool", + "internalType": "bool" + }, + { + "name": "priceControl", + "type": "uint8", + "internalType": "enum IFolio.PriceControl" + } + ] + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setTVLFee", + "inputs": [ + { + "name": "_newFee", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setTradeAllowlistEnabled", + "inputs": [ + { + "name": "_enabled", + "type": "bool", + "internalType": "bool" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setTrustedFillerRegistry", + "inputs": [ + { + "name": "_newFillerRegistry", + "type": "address", + "internalType": "address" + }, + { + "name": "_enabled", + "type": "bool", + "internalType": "bool" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "startRebalance", + "inputs": [ + { + "name": "rebalanceNonce", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "tokens", + "type": "tuple[]", + "internalType": "struct IFolio.TokenRebalanceParams[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "weight", + "type": "tuple", + "internalType": "struct IFolio.WeightRange", + "components": [ + { + "name": "low", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "spot", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "high", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "price", + "type": "tuple", + "internalType": "struct IFolio.PriceRange", + "components": [ + { + "name": "low", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "high", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "maxAuctionSize", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "inRebalance", + "type": "bool", + "internalType": "bool" + } + ] + }, + { + "name": "limits", + "type": "tuple", + "internalType": "struct IFolio.RebalanceLimits", + "components": [ + { + "name": "low", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "spot", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "high", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "auctionLauncherWindow", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "ttl", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "deadline", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "stateChangeActive", + "inputs": [], + "outputs": [ + { + "name": "syncStateChangeActive", + "type": "bool", + "internalType": "bool" + }, + { + "name": "asyncStateChangeActive", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "supportsInterface", + "inputs": [ + { + "name": "interfaceId", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "symbol", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "toAssets", + "inputs": [ + { + "name": "shares", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "rounding", + "type": "uint8", + "internalType": "enum Math.Rounding" + } + ], + "outputs": [ + { + "name": "_assets", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "_amounts", + "type": "uint256[]", + "internalType": "uint256[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "totalAssets", + "inputs": [], + "outputs": [ + { + "name": "_assets", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "_amounts", + "type": "uint256[]", + "internalType": "uint256[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "totalSupply", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "tradeAllowlistEnabled", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "transfer", + "inputs": [ + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "transferFrom", + "inputs": [ + { + "name": "from", + "type": "address", + "internalType": "address" + }, + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "trustedFillerEnabled", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "trustedFillerRegistry", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract ITrustedFillerRegistry" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "tvlFee", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "version", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "pure" + }, + { + "type": "event", + "name": "Approval", + "inputs": [ + { + "name": "owner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "spender", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AuctionBid", + "inputs": [ + { + "name": "auctionId", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "sellToken", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "buyToken", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "sellAmount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "buyAmount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AuctionClosed", + "inputs": [ + { + "name": "auctionId", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AuctionOpened", + "inputs": [ + { + "name": "rebalanceNonce", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "auctionId", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "tokens", + "type": "address[]", + "indexed": false, + "internalType": "address[]" + }, + { + "name": "weights", + "type": "tuple[]", + "indexed": false, + "internalType": "struct IFolio.WeightRange[]", + "components": [ + { + "name": "low", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "spot", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "high", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "prices", + "type": "tuple[]", + "indexed": false, + "internalType": "struct IFolio.PriceRange[]", + "components": [ + { + "name": "low", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "high", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "limits", + "type": "tuple", + "indexed": false, + "internalType": "struct IFolio.RebalanceLimits", + "components": [ + { + "name": "low", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "spot", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "high", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "startTime", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "endTime", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AuctionTrustedFillCreated", + "inputs": [ + { + "name": "auctionId", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "filler", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "BasketTokenAdded", + "inputs": [ + { + "name": "token", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "BasketTokenRemoved", + "inputs": [ + { + "name": "token", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "BidsEnabledSet", + "inputs": [ + { + "name": "bidsEnabled", + "type": "bool", + "indexed": false, + "internalType": "bool" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "FeeRecipientsSet", + "inputs": [ + { + "name": "recipients", + "type": "tuple[]", + "indexed": false, + "internalType": "struct IFolio.FeeRecipient[]", + "components": [ + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "portion", + "type": "uint96", + "internalType": "uint96" + } + ] + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "FolioDeprecated", + "inputs": [], + "anonymous": false + }, + { + "type": "event", + "name": "FolioFeePaid", + "inputs": [ + { + "name": "recipient", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "FolioFeeSet", + "inputs": [ + { + "name": "newFolioFee", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "ImmutableFeeRecipientsSet", + "inputs": [ + { + "name": "recipients", + "type": "tuple[]", + "indexed": false, + "internalType": "struct IFolio.FeeRecipient[]", + "components": [ + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "portion", + "type": "uint96", + "internalType": "uint96" + } + ] + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "MandateSet", + "inputs": [ + { + "name": "newMandate", + "type": "string", + "indexed": false, + "internalType": "string" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "MaxAuctionLengthSet", + "inputs": [ + { + "name": "newMaxAuctionLength", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "MintFeeSet", + "inputs": [ + { + "name": "newFee", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "NameSet", + "inputs": [ + { + "name": "newName", + "type": "string", + "indexed": false, + "internalType": "string" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "ProtocolFeePaid", + "inputs": [ + { + "name": "recipient", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RebalanceControlSet", + "inputs": [ + { + "name": "newControl", + "type": "tuple", + "indexed": false, + "internalType": "struct IFolio.RebalanceControl", + "components": [ + { + "name": "weightControl", + "type": "bool", + "internalType": "bool" + }, + { + "name": "priceControl", + "type": "uint8", + "internalType": "enum IFolio.PriceControl" + } + ] + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RebalanceEnded", + "inputs": [ + { + "name": "nonce", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RebalanceStarted", + "inputs": [ + { + "name": "nonce", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "priceControl", + "type": "uint8", + "indexed": false, + "internalType": "enum IFolio.PriceControl" + }, + { + "name": "tokens", + "type": "tuple[]", + "indexed": false, + "internalType": "struct IFolio.TokenRebalanceParams[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "weight", + "type": "tuple", + "internalType": "struct IFolio.WeightRange", + "components": [ + { + "name": "low", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "spot", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "high", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "price", + "type": "tuple", + "internalType": "struct IFolio.PriceRange", + "components": [ + { + "name": "low", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "high", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "maxAuctionSize", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "inRebalance", + "type": "bool", + "internalType": "bool" + } + ] + }, + { + "name": "limits", + "type": "tuple", + "indexed": false, + "internalType": "struct IFolio.RebalanceLimits", + "components": [ + { + "name": "low", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "spot", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "high", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "startedAt", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "restrictedUntil", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "availableUntil", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "bidsEnabled", + "type": "bool", + "indexed": false, + "internalType": "bool" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RoleAdminChanged", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "previousAdminRole", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "newAdminRole", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RoleGranted", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "sender", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RoleRevoked", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "sender", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "TVLFeeSet", + "inputs": [ + { + "name": "newFee", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "feeAnnually", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "TradeAllowlistEnabled", + "inputs": [ + { + "name": "enabled", + "type": "bool", + "indexed": false, + "internalType": "bool" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "TradeAllowlistTokenAdded", + "inputs": [ + { + "name": "token", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "TradeAllowlistTokenRemoved", + "inputs": [ + { + "name": "token", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Transfer", + "inputs": [ + { + "name": "from", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "to", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "TrustedFillerRegistrySet", + "inputs": [ + { + "name": "trustedFillerRegistry", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "isEnabled", + "type": "bool", + "indexed": false, + "internalType": "bool" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "AccessControlBadConfirmation", + "inputs": [] + }, + { + "type": "error", + "name": "AccessControlUnauthorizedAccount", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "neededRole", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "ERC20InsufficientAllowance", + "inputs": [ + { + "name": "spender", + "type": "address", + "internalType": "address" + }, + { + "name": "allowance", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "needed", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "ERC20InsufficientBalance", + "inputs": [ + { + "name": "sender", + "type": "address", + "internalType": "address" + }, + { + "name": "balance", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "needed", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "ERC20InvalidApprover", + "inputs": [ + { + "name": "approver", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ERC20InvalidReceiver", + "inputs": [ + { + "name": "receiver", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ERC20InvalidSender", + "inputs": [ + { + "name": "sender", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ERC20InvalidSpender", + "inputs": [ + { + "name": "spender", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "Folio__AuctionCannotBeOpenedWithoutRestriction", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__AuctionNotOngoing", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__BadFeeTotal", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__DeadlineExpired", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__DuplicateAsset", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__EmptyAssets", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__EmptyRebalance", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__FeeRecipientInvalidAddress", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__FeeRecipientInvalidFeeShare", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__FolioDeprecated", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__FolioFeeTooHigh", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__ImmutableFeeRecipientRemoved", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__InsufficientBid", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__InsufficientBuyAvailable", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__InsufficientSellAvailable", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__InsufficientSharesOut", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__InvalidArrayLengths", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__InvalidAsset", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__InvalidAssetAmount", + "inputs": [ + { + "name": "asset", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "Folio__InvalidAuctionLength", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__InvalidLimits", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__InvalidPrices", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__InvalidRebalanceNonce", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__InvalidRegistry", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__InvalidTTL", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__InvalidTransferToSelf", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__InvalidTrustedFill", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__InvalidWeights", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__MintFeeTooHigh", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__MixedAtomicSwaps", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__NotInRebalance", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__NotRebalancing", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__PermissionlessBidsDisabled", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__SlippageExceeded", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__TVLFeeTooHigh", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__TVLFeeTooLow", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__TokenNotAllowlisted", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__TooManyFeeRecipients", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__TrustedFillerRegistryAlreadySet", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__TrustedFillerRegistryNotEnabled", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__Unauthorized", + "inputs": [] + }, + { + "type": "error", + "name": "Folio__ZeroInitialShares", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidInitialization", + "inputs": [] + }, + { + "type": "error", + "name": "NotInitializing", + "inputs": [] + }, + { + "type": "error", + "name": "ReentrancyGuardReentrantCall", + "inputs": [] + }, + { + "type": "error", + "name": "SafeERC20FailedOperation", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ] + } +] as const; diff --git a/packages/sdk/src/index-dtf/abis/folio-version-registry.generated.ts b/packages/sdk/src/index-dtf/abis/folio-version-registry.generated.ts new file mode 100644 index 0000000..4ca884b --- /dev/null +++ b/packages/sdk/src/index-dtf/abis/folio-version-registry.generated.ts @@ -0,0 +1,198 @@ +// Generated by packages/sdk/scripts/sync-index-dtf-v6-abis.mjs. +// Source: reserve-protocol/reserve-index-dtf@7d97c807003c563038ec948e92eb43eabba43ef0 +// Artifact: out/FolioVersionRegistry.sol/FolioVersionRegistry.json +// Do not edit by hand; run `pnpm abi:index-v6:sync`. +export const folioVersionRegistryAbi = [ + { + "type": "constructor", + "inputs": [ + { + "name": "_roleRegistry", + "type": "address", + "internalType": "contract IRoleRegistry" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "deployments", + "inputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IFolioDeployer" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "deprecateVersion", + "inputs": [ + { + "name": "versionHash", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "getImplementationForVersion", + "inputs": [ + { + "name": "versionHash", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "folio", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getLatestVersion", + "inputs": [], + "outputs": [ + { + "name": "versionHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "version", + "type": "string", + "internalType": "string" + }, + { + "name": "folioDeployer", + "type": "address", + "internalType": "contract IFolioDeployer" + }, + { + "name": "deprecated", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isDeprecated", + "inputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "registerVersion", + "inputs": [ + { + "name": "folioDeployer", + "type": "address", + "internalType": "contract IFolioDeployer" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "roleRegistry", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IRoleRegistry" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "VersionDeprecated", + "inputs": [ + { + "name": "versionHash", + "type": "bytes32", + "indexed": false, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "VersionRegistered", + "inputs": [ + { + "name": "versionHash", + "type": "bytes32", + "indexed": false, + "internalType": "bytes32" + }, + { + "name": "folioDeployer", + "type": "address", + "indexed": false, + "internalType": "contract IFolioDeployer" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "VersionRegistry__AlreadyDeprecated", + "inputs": [] + }, + { + "type": "error", + "name": "VersionRegistry__InvalidCaller", + "inputs": [] + }, + { + "type": "error", + "name": "VersionRegistry__InvalidRegistration", + "inputs": [] + }, + { + "type": "error", + "name": "VersionRegistry__Unconfigured", + "inputs": [] + }, + { + "type": "error", + "name": "VersionRegistry__ZeroAddress", + "inputs": [] + } +] as const; diff --git a/packages/sdk/src/index-dtf/abis/governance-spell-09-18-2026.ts b/packages/sdk/src/index-dtf/abis/governance-spell-09-18-2026.ts new file mode 100644 index 0000000..80104a6 --- /dev/null +++ b/packages/sdk/src/index-dtf/abis/governance-spell-09-18-2026.ts @@ -0,0 +1,65 @@ +/** `GovernanceSpell_09_18_2026`: legacy Folio → optimistic governance 1.1.0 migration and old staking vault retirement. */ +export const governanceSpell09182026Abi = [ + { + type: "function", + name: "upgradeFolio", + inputs: [ + { name: "folio", type: "address" }, + { name: "folioProxyAdmin", type: "address" }, + { name: "newStakingVault", type: "address" }, + { name: "oldFolioGovernor", type: "address" }, + { name: "tradingGovernor", type: "address" }, + { + name: "optimisticParams", + type: "tuple", + components: [ + { name: "vetoDelay", type: "uint48" }, + { name: "vetoPeriod", type: "uint32" }, + { name: "vetoThreshold", type: "uint256" }, + ], + }, + { name: "optimisticProposers", type: "address[]" }, + { name: "guardians", type: "address[]" }, + { name: "newFeeRecipient", type: "address" }, + { name: "deploymentNonce", type: "bytes32" }, + ], + outputs: [ + { + name: "newDeployment", + type: "tuple", + components: [ + { name: "stakingVault", type: "address" }, + { name: "newGovernor", type: "address" }, + { name: "newTimelock", type: "address" }, + { name: "newSelectorRegistry", type: "address" }, + ], + }, + ], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "retireOldStakingVault", + inputs: [{ name: "oldStakingVault", type: "address" }], + outputs: [], + stateMutability: "nonpayable", + }, +] as const; + +/** OpenZeppelin `Ownable.transferOwnership`, for owned contracts outside the DTF's own map (e.g. a retiring staking vault). */ +export const ownableTransferOwnershipAbi = [ + { + type: "function", + name: "transferOwnership", + inputs: [{ name: "newOwner", type: "address" }], + outputs: [], + stateMutability: "nonpayable", + }, +] as const; + +/** Deployed `GovernanceSpell_09_18_2026` per chain. */ +export const INDEX_DTF_GOVERNANCE_SPELL_09_18_2026_ADDRESS = { + 1: "0x11B1bF67F0c6495E1F2d34cDe0296A02C2540926", + 8453: "0x5771d976696AA180Fed276FB6571fE2f41D0b849", + 56: "0x60C384e226b120d93f3e0F4C502957b2B9C32B15", +} as const; diff --git a/packages/sdk/src/index-dtf/abis/timelock.ts b/packages/sdk/src/index-dtf/abis/timelock.ts index 1db113e..3c92bcc 100644 --- a/packages/sdk/src/index-dtf/abis/timelock.ts +++ b/packages/sdk/src/index-dtf/abis/timelock.ts @@ -466,5 +466,3 @@ export const timelockAbi = [ }, { stateMutability: "payable", type: "receive" }, ] as const; - -export default timelockAbi; diff --git a/packages/sdk/src/index-dtf/abis/upgrade-spell-6-0-0.ts b/packages/sdk/src/index-dtf/abis/upgrade-spell-6-0-0.ts new file mode 100644 index 0000000..060873e --- /dev/null +++ b/packages/sdk/src/index-dtf/abis/upgrade-spell-6-0-0.ts @@ -0,0 +1,33 @@ +/** `UpgradeSpell_6_0_0`: the 5.0.0 → 6.0.0 migration spell (deployed bytecode matches reserve-index-dtf e4547b3, PR #212). */ +export const upgradeSpell600Abi = [ + { + type: "function", + name: "cast", + inputs: [ + { name: "folio", type: "address" }, + { name: "proxyAdmin", type: "address" }, + { name: "selectorRegistry", type: "address" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "version", + inputs: [], + outputs: [{ name: "", type: "string" }], + stateMutability: "pure", + }, + { + type: "error", + name: "UpgradeSpell__Error", + inputs: [{ name: "code", type: "uint256" }], + }, +] as const; + +/** Deployed `UpgradeSpell_6_0_0` per chain. */ +export const INDEX_DTF_UPGRADE_SPELL_6_0_0_ADDRESS = { + 1: "0x10479dE3B197eD50A1B5BA86cc2EC16F620f06dA", + 8453: "0x4bCd4101729C25F4e65B29357387440e357D43c1", + 56: "0x47df1465672bFc4dC83f712531F7Eb1D658C0B66", +} as const; diff --git a/packages/sdk/src/index-dtf/deploy/index.test.ts b/packages/sdk/src/index-dtf/deploy/index.test.ts index ff8d096..6681c7b 100644 --- a/packages/sdk/src/index-dtf/deploy/index.test.ts +++ b/packages/sdk/src/index-dtf/deploy/index.test.ts @@ -1,7 +1,16 @@ -import { decodeFunctionData, encodeAbiParameters, encodeEventTopics, parseEther, type Log } from "viem"; +import { + decodeFunctionData, + encodeAbiParameters, + encodeEventTopics, + erc20Abi, + parseEther, + type Address, + type Log, +} from "viem"; import { describe, expect, it } from "vitest"; import { indexDtfDeployerAbi } from "@/index-dtf/abis/deployer"; +import { folioDeployerV6Abi } from "@/index-dtf/abis/folio-deployer-v6.generated"; import { indexDtfGovernanceDeployerAbi } from "@/index-dtf/abis/governance-deployer"; import { buildIndexDtfDeployFeeRecipients, @@ -9,14 +18,19 @@ import { extractIndexDtfDeployedAddress, extractIndexDtfDeployedStakingTokenAddress, getIndexDtfDeployApprovalAmount, + getIndexDtfDeployerAddress, INDEX_DTF_DEPLOYER_ADDRESS, INDEX_DTF_GOVERNANCE_DEPLOYER_ADDRESS, + INDEX_DTF_V6_DEPLOYER_ADDRESS, prepareIndexDtfDeploy, + prepareIndexDtfDeployAssetApproval, prepareIndexDtfDeployAssetApprovals, prepareIndexDtfDeployGoverned, + prepareIndexDtfDeployGovernedPlan, prepareIndexDtfDeployPlan, prepareIndexDtfDeployStakingToken, } from "@/index-dtf/deploy/index"; +import { SdkError } from "@/lib/errors"; const DTF = "0x0000000000000000000000000000000000000001"; const OWNER = "0x0000000000000000000000000000000000000002"; @@ -38,10 +52,40 @@ const additionalDetails = { auctionLength: 1800n, feeRecipients: [{ recipient: OWNER, portion: parseEther("1") }], tvlFee: parseEther("0.0015"), - mintFee: parseEther("0.0015"), + mintFee: parseEther("0.0025"), mandate: "Test mandate", } as const; +// A non-canonical Folio 6.0 deployer: the mainnet sandbox fixture's (index-subgraph/.fork/fixture.json), used as an override. +const V6_DEPLOYER = "0x388dB009b7984E673938AC20480194D4481bEF11"; +const V6_DEPLOYER_LOWERCASE = "0x388db009b7984e673938ac20480194d4481bef11"; + +const additionalDetailsV6 = { + maxAuctionLength: 1800n, + feeRecipients: [{ recipient: OWNER, portion: parseEther("0.7") }], + immutableFeeRecipients: [{ recipient: ST_TOKEN, portion: parseEther("0.3") }], + tvlFee: parseEther("0.0015"), + mintFee: parseEther("0.0025"), + selfFee: parseEther("0.05"), + mandate: "Test mandate", +} as const; + +const optimisticGovernance = { + optimistic: { vetoDelay: 3600, vetoPeriod: 86_400, vetoThreshold: parseEther("0.1") }, + standard: { + votingDelay: 0, + votingPeriod: 86_400, + voteExtension: 600, + proposalThreshold: parseEther("0.01"), + quorumNumerator: 3n, + }, + optimisticSelectors: ["0xc1e54b89"], + optimisticProposers: [ADMIN], + additionalGuardians: [OWNER], + timelockDelay: 86_400n, + proposalThrottleCapacity: 5n, +} as const; + const governance = { votingDelay: 0, votingPeriod: 86_400, @@ -72,6 +116,7 @@ describe("Index DTF deploy builders", () => { it("prepares ungoverned deployFolio calls", () => { const call = prepareIndexDtfDeploy({ chainId: 8453, + version: "5.0.0", basicDetails, additionalDetails, flags: DEFAULT_INDEX_DTF_DEPLOY_FLAGS, @@ -87,30 +132,276 @@ describe("Index DTF deploy builders", () => { }); }); - it("prepares governed deployGovernedFolio calls", () => { + it("preserves distinct owner and trading governance in the complete deploy payload", () => { + const tradingGovernance = { + votingDelay: 60, + votingPeriod: 43_200, + proposalThreshold: parseEther("0.02"), + quorumThreshold: parseEther("0.04"), + timelockDelay: 3600n, + guardians: [OWNER], + } as const; const call = prepareIndexDtfDeployGoverned({ chainId: 1, + version: "5.0.0", stToken: ST_TOKEN, basicDetails, additionalDetails, flags: DEFAULT_INDEX_DTF_DEPLOY_FLAGS, ownerGovernance: governance, - tradingGovernance: governance, + tradingGovernance, roles: { auctionLaunchers: [ADMIN], brandManagers: [OWNER] }, deploymentNonce: NONCE, }); - expect(call.to).toBe(INDEX_DTF_DEPLOYER_ADDRESS[1]); + expect(call).toMatchObject({ to: INDEX_DTF_DEPLOYER_ADDRESS[1], chainId: 1, value: 0n }); const decoded = decodeFunctionData({ abi: indexDtfDeployerAbi, data: call.data }); - expect(decoded.functionName).toBe("deployGovernedFolio"); - expect(decoded.args[0]).toBe(ST_TOKEN); - expect(decoded.args[4]).toEqual(governance); - expect(decoded.args[6]).toEqual({ - existingBasketManagers: [], + expect(decoded).toEqual({ + functionName: "deployGovernedFolio", + args: [ + ST_TOKEN, + basicDetails, + additionalDetails, + DEFAULT_INDEX_DTF_DEPLOY_FLAGS, + governance, + tradingGovernance, + { existingBasketManagers: [], auctionLaunchers: [ADMIN], brandManagers: [OWNER] }, + NONCE, + ], + }); + }); + + it("encodes Folio 6.0 deployFolio against an overriding v6 deployer with the v6 additional details", () => { + const call = prepareIndexDtfDeploy({ + chainId: 1, + version: "6.0.0", + deployer: V6_DEPLOYER, + basicDetails, + additionalDetails: additionalDetailsV6, + flags: DEFAULT_INDEX_DTF_DEPLOY_FLAGS, + owner: OWNER, auctionLaunchers: [ADMIN], - brandManagers: [OWNER], + deploymentNonce: NONCE, }); + + expect(call).toMatchObject({ to: V6_DEPLOYER, chainId: 1, value: 0n }); + expect(decodeFunctionData({ abi: folioDeployerV6Abi, data: call.data })).toEqual({ + functionName: "deployFolio", + args: [ + basicDetails, + { + maxAuctionLength: 1800n, + feeRecipients: [{ recipient: OWNER, portion: parseEther("0.7") }], + immutableFeeRecipients: [{ recipient: ST_TOKEN, portion: parseEther("0.3") }], + tvlFee: parseEther("0.0015"), + mintFee: parseEther("0.0025"), + folioFeeForSelf: parseEther("0.05"), + mandate: "Test mandate", + }, + DEFAULT_INDEX_DTF_DEPLOY_FLAGS, + OWNER, + [], + [ADMIN], + [], + NONCE, + ], + }); + expect(() => decodeFunctionData({ abi: indexDtfDeployerAbi, data: call.data })).toThrow(); + }); + + it("encodes Folio 6.0 deployGovernedFolio with one optimistic governance table", () => { + const call = prepareIndexDtfDeployGoverned({ + chainId: 8453, + version: "6.0.0", + deployer: V6_DEPLOYER, + stToken: ST_TOKEN, + basicDetails, + additionalDetails: additionalDetailsV6, + flags: DEFAULT_INDEX_DTF_DEPLOY_FLAGS, + governance: optimisticGovernance, + roles: { auctionLaunchers: [ADMIN] }, + deploymentNonce: NONCE, + }); + + expect(call.to).toBe(V6_DEPLOYER); + const decoded = decodeFunctionData({ abi: folioDeployerV6Abi, data: call.data }); + expect(decoded.functionName).toBe("deployGovernedFolio"); + expect(decoded.args?.[4]).toEqual({ + optimisticParams: { vetoDelay: 3600, vetoPeriod: 86_400, vetoThreshold: parseEther("0.1") }, + standardParams: { + votingDelay: 0, + votingPeriod: 86_400, + voteExtension: 600, + proposalThreshold: parseEther("0.01"), + quorumNumerator: 3n, + }, + optimisticSelectors: ["0xc1e54b89"], + optimisticProposers: [ADMIN], + additionalGuardians: [OWNER], + timelockDelay: 86_400n, + proposalThrottleCapacity: 5n, + }); + expect(decoded.args?.[5]).toEqual({ existingBasketManagers: [], auctionLaunchers: [ADMIN], brandManagers: [] }); + expect(decoded.args?.[6]).toBe(NONCE); + }); + + it("points v6 plan approvals at an overriding deployer", () => { + const plan = prepareIndexDtfDeployGovernedPlan({ + chainId: 8453, + version: "6.0.0", + deployer: V6_DEPLOYER, + stToken: ST_TOKEN, + basicDetails, + additionalDetails: additionalDetailsV6, + flags: DEFAULT_INDEX_DTF_DEPLOY_FLAGS, + governance: optimisticGovernance, + approvals: [{ token: TOKEN_A, amount: 100n }], + }); + + expect(plan.type).toBe("approval-required"); + if (plan.type !== "approval-required") throw new Error("expected approvals"); + expect(decodeFunctionData({ abi: erc20Abi, data: plan.approvals[0]!.data })).toEqual({ + functionName: "approve", + args: [V6_DEPLOYER, 100n], + }); + }); + + it("routes ungoverned v6 plan approvals and plural approvals to the given deployer", () => { + const plan = prepareIndexDtfDeployPlan({ + chainId: 1, + version: "6.0.0", + deployer: V6_DEPLOYER, + basicDetails, + additionalDetails: additionalDetailsV6, + flags: DEFAULT_INDEX_DTF_DEPLOY_FLAGS, + owner: OWNER, + approvals: [{ token: TOKEN_A, amount: 100n }], + }); + if (plan.type !== "approval-required") throw new Error("expected approvals"); + expect(decodeFunctionData({ abi: erc20Abi, data: plan.approvals[0]!.data }).args).toEqual([V6_DEPLOYER, 100n]); + + const approvals = prepareIndexDtfDeployAssetApprovals({ + chainId: 1, + version: "6.0.0", + assets: [TOKEN_A, TOKEN_B], + amounts: [100n, 200n], + deployer: V6_DEPLOYER, + }); + expect(approvals.map((approval) => decodeFunctionData({ abi: erc20Abi, data: approval.data }).args)).toEqual([ + [V6_DEPLOYER, 200n], + [V6_DEPLOYER, 400n], + ]); + }); + + it("defaults v6 deploys, governed deploys and their plan approvals to the chain's 6.0.0 deployer", () => { + // Pinned so an edit to the map fails here, not only in the opt-in real-deployer fork suite. + expect(INDEX_DTF_V6_DEPLOYER_ADDRESS).toEqual({ + 1: "0x2B1Cd9aEF0CD3B9fF5DCa1C66348eCfC46F37392", + 8453: "0x4c891fCa6319d492866672E3D2AfdAAA5bDcfF67", + 56: "0x9837Ce9825D52672Ca02533B5A160212bf901963", + }); + for (const chainId of [1, 8453, 56] as const) { + const deployer = INDEX_DTF_V6_DEPLOYER_ADDRESS[chainId]; + const plan = prepareIndexDtfDeployPlan({ + chainId, + version: "6.0.0", + basicDetails, + additionalDetails: additionalDetailsV6, + flags: DEFAULT_INDEX_DTF_DEPLOY_FLAGS, + owner: OWNER, + approvals: [{ token: TOKEN_A, amount: 100n }], + }); + const governedPlan = prepareIndexDtfDeployGovernedPlan({ + chainId, + version: "6.0.0", + stToken: ST_TOKEN, + basicDetails, + additionalDetails: additionalDetailsV6, + flags: DEFAULT_INDEX_DTF_DEPLOY_FLAGS, + governance: optimisticGovernance, + approvals: [{ token: TOKEN_B, amount: 200n }], + }); + if (plan.type !== "approval-required" || governedPlan.type !== "approval-required") { + throw new Error("expected approval plans"); + } + + expect(deployer).not.toBe(INDEX_DTF_DEPLOYER_ADDRESS[chainId]); + expect(getIndexDtfDeployerAddress({ chainId, version: "6.0.0" })).toBe(deployer); + expect(plan.call.to, `${chainId} deploy`).toBe(deployer); + expect(decodeFunctionData({ abi: folioDeployerV6Abi, data: plan.call.data }).functionName).toBe("deployFolio"); + expect(governedPlan.call.to, `${chainId} governed deploy`).toBe(deployer); + expect(decodeFunctionData({ abi: erc20Abi, data: plan.approvals[0]!.data }).args).toEqual([deployer, 100n]); + expect(decodeFunctionData({ abi: erc20Abi, data: governedPlan.approvals[0]!.data }).args).toEqual([ + deployer, + 200n, + ]); + } + }); + + it("points standalone deploy approvals at the version's deployer and keeps an explicit override", () => { + const spenderOf = (params: Parameters[0]) => + decodeFunctionData({ abi: erc20Abi, data: prepareIndexDtfDeployAssetApproval(params).data }).args?.[0]; + + expect(spenderOf({ chainId: 56, version: "6.0.0", token: TOKEN_A, amount: 1n })).toBe( + INDEX_DTF_V6_DEPLOYER_ADDRESS[56], + ); + expect(spenderOf({ chainId: 56, version: "5.0.0", token: TOKEN_A, amount: 1n })).toBe( + INDEX_DTF_DEPLOYER_ADDRESS[56], + ); + expect( + spenderOf({ chainId: 56, version: "6.0.0", token: TOKEN_A, amount: 1n, deployer: V6_DEPLOYER_LOWERCASE }), + ).toBe(V6_DEPLOYER); + expect( + prepareIndexDtfDeployAssetApprovals({ chainId: 8453, version: "6.0.0", assets: [TOKEN_A], amounts: [5n] }).map( + (approval) => decodeFunctionData({ abi: erc20Abi, data: approval.data }).args, + ), + ).toEqual([[INDEX_DTF_V6_DEPLOYER_ADDRESS[8453], 10n]]); + expect(getIndexDtfDeployerAddress({ chainId: 1, version: "6.0.0", deployer: V6_DEPLOYER_LOWERCASE })).toBe( + V6_DEPLOYER, + ); + }); + + it("rejects out-of-range v6 details and malformed optimistic selectors", () => { + const base = { + chainId: 1, + version: "6.0.0", + deployer: V6_DEPLOYER, + basicDetails, + flags: DEFAULT_INDEX_DTF_DEPLOY_FLAGS, + owner: OWNER, + } as const; + expect(() => + prepareIndexDtfDeploy({ ...base, additionalDetails: { ...additionalDetailsV6, maxAuctionLength: 60n } }), + ).toThrow("maxAuctionLength"); + expect(() => + prepareIndexDtfDeployGoverned({ + ...base, + stToken: ST_TOKEN, + additionalDetails: additionalDetailsV6, + governance: { ...optimisticGovernance, optimisticSelectors: [NONCE] }, + }), + ).toThrow("4-byte"); + }); + + it("rejects a self fee above 100% and unknown deploy versions", () => { + const build = () => + prepareIndexDtfDeploy({ + chainId: 1, + version: "6.0.0", + deployer: V6_DEPLOYER, + basicDetails, + additionalDetails: { ...additionalDetailsV6, selfFee: parseEther("1.01") }, + flags: DEFAULT_INDEX_DTF_DEPLOY_FLAGS, + owner: OWNER, + }); + expect(build).toThrow(SdkError); + expect(build).toThrow(expect.objectContaining({ code: "INVALID_INPUT" })); + + const unknown = () => getIndexDtfDeployerAddress({ chainId: 1, version: "4.0.0" as unknown as "5.0.0" }); + expect(unknown).toThrow(SdkError); + expect(unknown).toThrow(expect.objectContaining({ code: "INVALID_INPUT", meta: { version: "4.0.0" } })); + expect(getIndexDtfDeployerAddress({ chainId: 56, version: "5.0.0" })).toBe(INDEX_DTF_DEPLOYER_ADDRESS[56]); }); it("prepares governed staking token deploy calls", () => { @@ -133,20 +424,39 @@ describe("Index DTF deploy builders", () => { it("prepares deploy asset approvals with Register's 2x default buffer", () => { const approvals = prepareIndexDtfDeployAssetApprovals({ chainId: 8453, + version: "5.0.0", assets: [TOKEN_A, TOKEN_B], amounts: [100n, 200n], }); expect(getIndexDtfDeployApprovalAmount({ amount: 100n })).toBe(200n); - expect(approvals.map((approval) => approval.contract.args)).toEqual([ - [INDEX_DTF_DEPLOYER_ADDRESS[8453], 200n], - [INDEX_DTF_DEPLOYER_ADDRESS[8453], 400n], + expect( + approvals.map(({ to, chainId, value, data }) => ({ + to, + chainId, + value, + decoded: decodeFunctionData({ abi: erc20Abi, data }), + })), + ).toEqual([ + { + to: TOKEN_A, + chainId: 8453, + value: 0n, + decoded: { functionName: "approve", args: [INDEX_DTF_DEPLOYER_ADDRESS[8453], 200n] }, + }, + { + to: TOKEN_B, + chainId: 8453, + value: 0n, + decoded: { functionName: "approve", args: [INDEX_DTF_DEPLOYER_ADDRESS[8453], 400n] }, + }, ]); }); it("prepares deploy plans with approvals on the deploy chain", () => { const plan = prepareIndexDtfDeployPlan({ chainId: 8453, + version: "5.0.0", basicDetails, additionalDetails, flags: DEFAULT_INDEX_DTF_DEPLOY_FLAGS, @@ -162,48 +472,136 @@ describe("Index DTF deploy builders", () => { expect(plan.approvals[0]?.contract.args).toEqual([INDEX_DTF_DEPLOYER_ADDRESS[8453], 100n]); }); - it("extracts deployed DTF addresses from deploy logs", () => { - const log = { - address: INDEX_DTF_DEPLOYER_ADDRESS[1], - topics: encodeEventTopics({ - abi: indexDtfDeployerAbi, - eventName: "FolioDeployed", - args: { folioOwner: OWNER, folio: DTF }, - }), - data: encodeAbiParameters([{ type: "address" }], [ADMIN]), - } as unknown as Log; + describe("deploy event extraction", () => { + const FORGED = "0x00000000000000000000000000000000000000f0"; + const OTHER_DTF = "0x0000000000000000000000000000000000000007"; - expect(extractIndexDtfDeployedAddress([log])).toBe(DTF); - }); + function folioDeployedLog(emitter: Address, folio: Address): Log { + return { + address: emitter, + topics: encodeEventTopics({ + abi: indexDtfDeployerAbi, + eventName: "FolioDeployed", + args: { folioOwner: OWNER, folio }, + }), + data: encodeAbiParameters([{ type: "address" }], [ADMIN]), + } as unknown as Log; + } - it("extracts governed deployed DTF addresses from deploy logs", () => { - const log = { - address: INDEX_DTF_DEPLOYER_ADDRESS[1], - topics: encodeEventTopics({ - abi: indexDtfDeployerAbi, - eventName: "GovernedFolioDeployed", - args: { stToken: ST_TOKEN, folio: DTF }, - }), - data: encodeAbiParameters( - [{ type: "address" }, { type: "address" }, { type: "address" }, { type: "address" }], - [OWNER, TOKEN_A, ADMIN, TOKEN_B], - ), - } as unknown as Log; + function governedFolioDeployedLog(emitter: Address, folio: Address): Log { + return { + address: emitter, + topics: encodeEventTopics({ + abi: indexDtfDeployerAbi, + eventName: "GovernedFolioDeployed", + args: { stToken: ST_TOKEN, folio }, + }), + data: encodeAbiParameters( + [{ type: "address" }, { type: "address" }, { type: "address" }, { type: "address" }], + [OWNER, TOKEN_A, ADMIN, TOKEN_B], + ), + } as unknown as Log; + } - expect(extractIndexDtfDeployedAddress([log])).toBe(DTF); - }); + function stakingTokenLog(emitter: Address, stToken: Address): Log { + return { + address: emitter, + topics: encodeEventTopics({ + abi: indexDtfGovernanceDeployerAbi, + eventName: "DeployedGovernedStakingToken", + args: { underlying: TOKEN_A, stToken }, + }), + data: encodeAbiParameters([{ type: "address" }, { type: "address" }], [OWNER, ADMIN]), + } as unknown as Log; + } - it("extracts deployed staking token addresses from governance deploy logs", () => { - const log = { - address: INDEX_DTF_GOVERNANCE_DEPLOYER_ADDRESS[1], - topics: encodeEventTopics({ - abi: indexDtfGovernanceDeployerAbi, - eventName: "DeployedGovernedStakingToken", - args: { underlying: TOKEN_A, stToken: ST_TOKEN }, - }), - data: encodeAbiParameters([{ type: "address" }, { type: "address" }], [OWNER, ADMIN]), - } as unknown as Log; + it("extracts the Folio from the version's deployer, ungoverned and governed", () => { + const v5 = { chainId: 1, version: "5.0.0" } as const; + const v6 = { chainId: 1, version: "6.0.0" } as const; + + expect(extractIndexDtfDeployedAddress([folioDeployedLog(INDEX_DTF_DEPLOYER_ADDRESS[1], DTF)], v5)).toBe(DTF); + expect( + extractIndexDtfDeployedAddress( + [ + folioDeployedLog(INDEX_DTF_V6_DEPLOYER_ADDRESS[1], DTF), + governedFolioDeployedLog(INDEX_DTF_V6_DEPLOYER_ADDRESS[1], DTF), + ], + v6, + ), + ).toBe(DTF); + }); + + it("ignores lookalike events from any other emitter, before or after the deployer's", () => { + // A basket token can emit a forged FolioDeployed during transferFrom, before the deployer emits the real one. + const logs = [ + folioDeployedLog(FORGED, OTHER_DTF), + governedFolioDeployedLog(FORGED, OTHER_DTF), + folioDeployedLog(INDEX_DTF_DEPLOYER_ADDRESS[8453], DTF), + folioDeployedLog(TOKEN_A, OTHER_DTF), + ]; - expect(extractIndexDtfDeployedStakingTokenAddress([log])).toBe(ST_TOKEN); + expect(extractIndexDtfDeployedAddress(logs, { chainId: 8453, version: "5.0.0" })).toBe(DTF); + }); + + it("rejects receipts where only another contract emitted the event", () => { + const forgedOnly = [folioDeployedLog(FORGED, OTHER_DTF), governedFolioDeployedLog(TOKEN_A, OTHER_DTF)]; + const build = () => extractIndexDtfDeployedAddress(forgedOnly, { chainId: 1, version: "5.0.0" }); + + expect(build).toThrow(SdkError); + expect(build).toThrow( + expect.objectContaining({ code: "RECORD_NOT_FOUND", meta: { deployer: INDEX_DTF_DEPLOYER_ADDRESS[1] } }), + ); + // The v5 deployer is not the v6 target's emitter, and a v6 default is not an override's emitter. + expect(() => + extractIndexDtfDeployedAddress([folioDeployedLog(INDEX_DTF_DEPLOYER_ADDRESS[1], DTF)], { + chainId: 1, + version: "6.0.0", + }), + ).toThrow(expect.objectContaining({ code: "RECORD_NOT_FOUND" })); + expect(() => + extractIndexDtfDeployedAddress([folioDeployedLog(INDEX_DTF_V6_DEPLOYER_ADDRESS[1], DTF)], { + chainId: 1, + version: "6.0.0", + deployer: ADMIN, + }), + ).toThrow(expect.objectContaining({ code: "RECORD_NOT_FOUND", meta: { deployer: ADMIN } })); + }); + + it("uses a v6 deployer override as the only accepted emitter", () => { + const logs = [folioDeployedLog(INDEX_DTF_V6_DEPLOYER_ADDRESS[56], OTHER_DTF), folioDeployedLog(ADMIN, DTF)]; + + expect(extractIndexDtfDeployedAddress(logs, { chainId: 56, version: "6.0.0", deployer: ADMIN })).toBe(DTF); + }); + + it("rejects a deployer receipt that names more than one Folio", () => { + const logs = [ + folioDeployedLog(INDEX_DTF_DEPLOYER_ADDRESS[1], DTF), + folioDeployedLog(INDEX_DTF_DEPLOYER_ADDRESS[1], OTHER_DTF), + ]; + const build = () => extractIndexDtfDeployedAddress(logs, { chainId: 1, version: "5.0.0" }); + + expect(build).toThrow(expect.objectContaining({ code: "INVALID_RESPONSE", meta: expect.anything() })); + expect(build).toThrow(/more than one address/); + }); + + it("extracts the staking token only from the chain's governance deployer", () => { + const genuine = stakingTokenLog(INDEX_DTF_GOVERNANCE_DEPLOYER_ADDRESS[1], ST_TOKEN); + const forged = stakingTokenLog(FORGED, OTHER_DTF); + + expect(extractIndexDtfDeployedStakingTokenAddress([forged, genuine], { chainId: 1 })).toBe(ST_TOKEN); + expect(() => extractIndexDtfDeployedStakingTokenAddress([forged], { chainId: 1 })).toThrow( + expect.objectContaining({ code: "RECORD_NOT_FOUND" }), + ); + // Mainnet's governance deployer is not Base's. + expect(() => extractIndexDtfDeployedStakingTokenAddress([genuine], { chainId: 8453 })).toThrow( + expect.objectContaining({ code: "RECORD_NOT_FOUND" }), + ); + expect(() => + extractIndexDtfDeployedStakingTokenAddress( + [genuine, stakingTokenLog(INDEX_DTF_GOVERNANCE_DEPLOYER_ADDRESS[1], OTHER_DTF)], + { chainId: 1 }, + ), + ).toThrow(expect.objectContaining({ code: "INVALID_RESPONSE" })); + }); }); }); diff --git a/packages/sdk/src/index-dtf/deploy/index.ts b/packages/sdk/src/index-dtf/deploy/index.ts index eed6fa9..718f2df 100644 --- a/packages/sdk/src/index-dtf/deploy/index.ts +++ b/packages/sdk/src/index-dtf/deploy/index.ts @@ -1,23 +1,42 @@ -import { getAddress, keccak256, parseEventLogs, type Address, type Hex, type Log } from "viem"; +import { getAddress, isAddressEqual, keccak256, parseEventLogs, type Address, type Hex, type Log } from "viem"; import type { SupportedChainId } from "@/config"; -import type { IndexDtfFeeRecipient, IndexDtfRevenueRecipientInput } from "@/index-dtf/governance/propose/revenue"; +import type { IndexDtfFeeRecipient } from "@/index-dtf/fee-recipients"; +import type { IndexDtfRevenueRecipientInput } from "@/index-dtf/governance/propose/revenue"; import type { ContractCallPlan } from "@/lib/contract-call"; +import type { IndexDtfCall } from "@/types/governance"; import type { PriceControl } from "@/types/index-dtf"; import { indexDtfDeployerAbi } from "@/index-dtf/abis/deployer"; +import { folioDeployerV6Abi } from "@/index-dtf/abis/folio-deployer-v6.generated"; import { indexDtfGovernanceDeployerAbi } from "@/index-dtf/abis/governance-deployer"; +import { assertIndexDtfFeeRecipientTables, sortIndexDtfFeeRecipients } from "@/index-dtf/fee-recipients"; import { buildIndexDtfFeeRecipients } from "@/index-dtf/governance/propose/revenue"; import { prepareContractCall, prepareErc20Approval } from "@/lib/contract-call"; import { SdkError } from "@/lib/errors"; import { toUint, toUintNumber } from "@/lib/utils"; +// Folio 6.0 bounds: MAX_FOLIO_FEE = 1e18, MIN/MAX_AUCTION_LENGTH = 120s / 1 week (contracts/utils/Constants.sol). +const MAX_SELF_FEE = 1_000_000_000_000_000_000n; +const MIN_AUCTION_LENGTH = 120n; +const MAX_AUCTION_LENGTH = 604_800n; + export const INDEX_DTF_DEPLOYER_ADDRESS = { 1: "0x4D201a6e5BF975E2CEE9e5cbDfc803C0Ff122073", 8453: "0x3451fD177E9a8bB4Eb8271E627A804BD22A816F9", 56: "0x72f87239981159ed23673012EE3806Ca6114AB2A", } as const satisfies Record; +/** + * Folio 6.0 `FolioDeployer` per chain (`version()` 6.0.0). v6 deploys target it whether or not the chain's + * `FolioVersionRegistry` has registered it yet; `getIndexDtfLatestVersion` reports the registry's view. + */ +export const INDEX_DTF_V6_DEPLOYER_ADDRESS = { + 1: "0x2B1Cd9aEF0CD3B9fF5DCa1C66348eCfC46F37392", + 8453: "0x4c891fCa6319d492866672E3D2AfdAAA5bDcfF67", + 56: "0x9837Ce9825D52672Ca02533B5A160212bf901963", +} as const satisfies Record; + export const INDEX_DTF_GOVERNANCE_DEPLOYER_ADDRESS = { 1: "0x72f87239981159ed23673012EE3806Ca6114AB2A", 8453: "0xECA52a5BDBAd98a5B4B6B944C4C9cc636D4D7461", @@ -49,6 +68,19 @@ export type IndexDtfDeployAdditionalDetails = { readonly mandate: string; }; +/** Folio 6.0 deploy details; fees are raw D18 values like the v5 shape (settings proposals take percents). */ +export type IndexDtfDeployAdditionalDetailsV6 = { + /** Seconds, 120 to 604800. */ + readonly maxAuctionLength: bigint; + readonly feeRecipients: readonly IndexDtfFeeRecipient[]; + readonly immutableFeeRecipients: readonly IndexDtfFeeRecipient[]; + readonly tvlFee: bigint; + readonly mintFee: bigint; + /** D18 fraction of non-DAO fees kept for holders (`folioFeeForSelf`), at most 1e18. */ + readonly selfFee: bigint; + readonly mandate: string; +}; + export type IndexDtfDeployFlags = { readonly trustedFillerEnabled: boolean; readonly rebalanceControl: { @@ -67,6 +99,27 @@ export type IndexDtfDeployGovernanceParams = { readonly guardians: readonly Address[]; }; +/** `IFolioDeployer.GovParams` for Folio 6.0: one optimistic governor for owner and trading. */ +export type IndexDtfDeployOptimisticGovernanceParams = { + readonly optimistic: { + readonly vetoDelay: number | bigint; + readonly vetoPeriod: number | bigint; + readonly vetoThreshold: bigint; + }; + readonly standard: { + readonly votingDelay: number | bigint; + readonly votingPeriod: number | bigint; + readonly voteExtension: number | bigint; + readonly proposalThreshold: bigint; + readonly quorumNumerator: bigint; + }; + readonly optimisticSelectors: readonly Hex[]; + readonly optimisticProposers: readonly Address[]; + readonly additionalGuardians: readonly Address[]; + readonly timelockDelay: number | bigint; + readonly proposalThrottleCapacity: bigint; +}; + export type IndexDtfDeployGovernanceRoles = { readonly existingBasketManagers?: readonly Address[]; readonly auctionLaunchers?: readonly Address[]; @@ -82,10 +135,26 @@ export type IndexDtfDeployRevenueDistributionParams = { readonly voteLock?: Address; }; -export type PrepareIndexDtfDeployParams = { +/** + * v5 deploys through `INDEX_DTF_DEPLOYER_ADDRESS`; v6 through `INDEX_DTF_V6_DEPLOYER_ADDRESS` unless `deployer` + * overrides it (forks, sandboxes). + */ +export type IndexDtfDeployTargetV5 = { + readonly chainId: SupportedChainId; + readonly version: "5.0.0"; +}; + +export type IndexDtfDeployTargetV6 = { readonly chainId: SupportedChainId; + readonly version: "6.0.0"; + /** Override for forks and sandboxes; defaults to the chain's `INDEX_DTF_V6_DEPLOYER_ADDRESS`. */ + readonly deployer?: Address; +}; + +type IndexDtfDeployVersion = (IndexDtfDeployTargetV5 | IndexDtfDeployTargetV6)["version"]; + +type IndexDtfDeployCommon = { readonly basicDetails: IndexDtfDeployBasicDetails; - readonly additionalDetails: IndexDtfDeployAdditionalDetails; readonly flags: IndexDtfDeployFlags; readonly owner: Address; readonly basketManagers?: readonly Address[]; @@ -94,18 +163,39 @@ export type PrepareIndexDtfDeployParams = { readonly deploymentNonce?: Hex; }; -export type PrepareIndexDtfDeployGovernedParams = { - readonly chainId: SupportedChainId; +export type PrepareIndexDtfDeployParamsV5 = IndexDtfDeployTargetV5 & + IndexDtfDeployCommon & { readonly additionalDetails: IndexDtfDeployAdditionalDetails }; + +export type PrepareIndexDtfDeployParamsV6 = IndexDtfDeployTargetV6 & + IndexDtfDeployCommon & { readonly additionalDetails: IndexDtfDeployAdditionalDetailsV6 }; + +export type PrepareIndexDtfDeployParams = PrepareIndexDtfDeployParamsV5 | PrepareIndexDtfDeployParamsV6; + +type IndexDtfDeployGovernedCommon = { readonly stToken: Address; readonly basicDetails: IndexDtfDeployBasicDetails; - readonly additionalDetails: IndexDtfDeployAdditionalDetails; readonly flags: IndexDtfDeployFlags; - readonly ownerGovernance: IndexDtfDeployGovernanceParams; - readonly tradingGovernance: IndexDtfDeployGovernanceParams; readonly roles?: IndexDtfDeployGovernanceRoles; readonly deploymentNonce?: Hex; }; +export type PrepareIndexDtfDeployGovernedParamsV5 = IndexDtfDeployTargetV5 & + IndexDtfDeployGovernedCommon & { + readonly additionalDetails: IndexDtfDeployAdditionalDetails; + readonly ownerGovernance: IndexDtfDeployGovernanceParams; + readonly tradingGovernance: IndexDtfDeployGovernanceParams; + }; + +export type PrepareIndexDtfDeployGovernedParamsV6 = IndexDtfDeployTargetV6 & + IndexDtfDeployGovernedCommon & { + readonly additionalDetails: IndexDtfDeployAdditionalDetailsV6; + readonly governance: IndexDtfDeployOptimisticGovernanceParams; + }; + +export type PrepareIndexDtfDeployGovernedParams = + | PrepareIndexDtfDeployGovernedParamsV5 + | PrepareIndexDtfDeployGovernedParamsV6; + export type PrepareIndexDtfDeployStakingTokenParams = { readonly chainId: SupportedChainId; readonly name: string; @@ -117,8 +207,12 @@ export type PrepareIndexDtfDeployStakingTokenParams = { export type PrepareIndexDtfDeployApprovalParams = { readonly chainId: SupportedChainId; + /** The Folio version being deployed; the spender defaults to that version's per-chain deployer. */ + readonly version: IndexDtfDeployVersion; readonly token: Address; readonly amount: bigint; + /** Spender override for forks and sandboxes. */ + readonly deployer?: Address; }; export type PrepareIndexDtfDeployPlanApprovalParams = { @@ -128,9 +222,11 @@ export type PrepareIndexDtfDeployPlanApprovalParams = { export type PrepareIndexDtfDeployApprovalsParams = { readonly chainId: SupportedChainId; + readonly version: IndexDtfDeployVersion; readonly assets: readonly Address[]; readonly amounts: readonly bigint[]; readonly approvalBufferBps?: number; + readonly deployer?: Address; }; export type PrepareIndexDtfDeployPlanParams = PrepareIndexDtfDeployParams & { @@ -161,10 +257,46 @@ export function generateIndexDtfDeploymentNonce(): Hex { return keccak256(bytes); } -export function prepareIndexDtfDeploy(params: PrepareIndexDtfDeployParams) { +export function getIndexDtfDeployerAddress(target: IndexDtfDeployTargetV5 | IndexDtfDeployTargetV6): Address { + if (target.version === "6.0.0") { + return target.deployer ? getAddress(target.deployer) : INDEX_DTF_V6_DEPLOYER_ADDRESS[target.chainId]; + } + if (target.version !== "5.0.0") { + throw new SdkError({ + code: "INVALID_INPUT", + message: `Unsupported Index DTF deploy version: ${String((target as { version: unknown }).version)}`, + meta: { version: (target as { version: unknown }).version }, + }); + } + + return INDEX_DTF_DEPLOYER_ADDRESS[target.chainId]; +} + +export function prepareIndexDtfDeploy(params: PrepareIndexDtfDeployParams): IndexDtfCall { + const address = getIndexDtfDeployerAddress(params); + + if (params.version === "6.0.0") { + return prepareContractCall({ + chainId: params.chainId, + address, + abi: folioDeployerV6Abi, + functionName: "deployFolio", + args: [ + normalizeBasicDetails(params.basicDetails), + normalizeAdditionalDetailsV6(params.additionalDetails), + normalizeFlags(params.flags), + getAddress(params.owner), + normalizeAddresses(params.basketManagers ?? []), + normalizeAddresses(params.auctionLaunchers ?? []), + normalizeAddresses(params.brandManagers ?? []), + params.deploymentNonce ?? generateIndexDtfDeploymentNonce(), + ] as const, + }); + } + return prepareContractCall({ chainId: params.chainId, - address: INDEX_DTF_DEPLOYER_ADDRESS[params.chainId], + address, abi: indexDtfDeployerAbi, functionName: "deployFolio", args: [ @@ -180,10 +312,30 @@ export function prepareIndexDtfDeploy(params: PrepareIndexDtfDeployParams) { }); } -export function prepareIndexDtfDeployGoverned(params: PrepareIndexDtfDeployGovernedParams) { +export function prepareIndexDtfDeployGoverned(params: PrepareIndexDtfDeployGovernedParams): IndexDtfCall { + const address = getIndexDtfDeployerAddress(params); + + if (params.version === "6.0.0") { + return prepareContractCall({ + chainId: params.chainId, + address, + abi: folioDeployerV6Abi, + functionName: "deployGovernedFolio", + args: [ + getAddress(params.stToken), + normalizeBasicDetails(params.basicDetails), + normalizeAdditionalDetailsV6(params.additionalDetails), + normalizeFlags(params.flags), + normalizeOptimisticGovernanceParams(params.governance), + normalizeGovernanceRoles(params.roles), + params.deploymentNonce ?? generateIndexDtfDeploymentNonce(), + ] as const, + }); + } + return prepareContractCall({ chainId: params.chainId, - address: INDEX_DTF_DEPLOYER_ADDRESS[params.chainId], + address, abi: indexDtfDeployerAbi, functionName: "deployGovernedFolio", args: [ @@ -222,8 +374,10 @@ export function prepareIndexDtfDeployPlan( const approvals = (params.approvals ?? []).map((approval) => prepareIndexDtfDeployAssetApproval({ chainId: params.chainId, + version: params.version, token: approval.token, amount: approval.amount, + deployer: call.to, }), ); @@ -240,8 +394,10 @@ export function prepareIndexDtfDeployGovernedPlan( const approvals = (params.approvals ?? []).map((approval) => prepareIndexDtfDeployAssetApproval({ chainId: params.chainId, + version: params.version, token: approval.token, amount: approval.amount, + deployer: call.to, }), ); @@ -249,10 +405,14 @@ export function prepareIndexDtfDeployGovernedPlan( } export function prepareIndexDtfDeployAssetApproval(params: PrepareIndexDtfDeployApprovalParams) { + const spender = params.deployer + ? getAddress(params.deployer) + : getIndexDtfDeployerAddress({ chainId: params.chainId, version: params.version }); + return prepareErc20Approval({ chainId: params.chainId, token: params.token, - spender: INDEX_DTF_DEPLOYER_ADDRESS[params.chainId], + spender, amount: params.amount, }); } @@ -271,7 +431,9 @@ export function prepareIndexDtfDeployAssetApprovals( return params.assets.map((token, index) => prepareIndexDtfDeployAssetApproval({ chainId: params.chainId, + version: params.version, token, + ...(params.deployer ? { deployer: params.deployer } : {}), amount: getIndexDtfDeployApprovalAmount({ amount: params.amounts[index] ?? 0n, approvalBufferBps: params.approvalBufferBps, @@ -300,48 +462,73 @@ export function getIndexDtfDeployApprovalAmount(params: { return (params.amount * BigInt(approvalBufferBps)) / 10_000n; } -export function extractIndexDtfDeployedAddress(logs: readonly Log[]): Address { - const governed = parseEventLogs({ - abi: indexDtfDeployerAbi, - logs: [...logs], - eventName: "GovernedFolioDeployed", - }); - - if (governed[0]?.args.folio) { - return governed[0].args.folio; - } - - const ungoverned = parseEventLogs({ - abi: indexDtfDeployerAbi, - logs: [...logs], - eventName: "FolioDeployed", +/** + * Reads the deployed Folio from a deploy receipt's logs. Only `FolioDeployed` / `GovernedFolioDeployed` emitted by the + * deployer the call was sent to count: every contract the deploy touches (a basket token during `transferFrom`, for + * one) can emit a lookalike event. Pass the same `{ chainId, version, deployer? }` the deploy builder took. Throws + * `RECORD_NOT_FOUND` without a deployer event and `INVALID_RESPONSE` when the deployer's events name more than one Folio. + */ +export function extractIndexDtfDeployedAddress( + logs: readonly Log[], + target: IndexDtfDeployTargetV5 | IndexDtfDeployTargetV6, +): Address { + const deployer = getIndexDtfDeployerAddress(target); + const events = parseEventLogs({ + abi: target.version === "6.0.0" ? folioDeployerV6Abi : indexDtfDeployerAbi, + logs: logs.filter((log) => isAddressEqual(log.address, deployer)), + eventName: ["FolioDeployed", "GovernedFolioDeployed"], }); - if (ungoverned[0]?.args.folio) { - return ungoverned[0].args.folio; - } - - throw new SdkError({ - code: "RECORD_NOT_FOUND", - message: "Could not find FolioDeployed or GovernedFolioDeployed event in transaction logs", - }); + return getSingleDeployedAddress( + events.map((event) => event.args.folio), + { deployer, events: "FolioDeployed or GovernedFolioDeployed" }, + ); } -export function extractIndexDtfDeployedStakingTokenAddress(logs: readonly Log[]): Address { +/** + * Reads the staking token from a `prepareIndexDtfDeployStakingToken` receipt: only `DeployedGovernedStakingToken` + * emitted by the chain's `INDEX_DTF_GOVERNANCE_DEPLOYER_ADDRESS` counts, with the same errors as + * `extractIndexDtfDeployedAddress`. + */ +export function extractIndexDtfDeployedStakingTokenAddress( + logs: readonly Log[], + target: { readonly chainId: SupportedChainId }, +): Address { + const deployer = INDEX_DTF_GOVERNANCE_DEPLOYER_ADDRESS[target.chainId]; const events = parseEventLogs({ abi: indexDtfGovernanceDeployerAbi, - logs: [...logs], + logs: logs.filter((log) => isAddressEqual(log.address, deployer)), eventName: "DeployedGovernedStakingToken", }); - if (events[0]?.args.stToken) { - return events[0].args.stToken; + return getSingleDeployedAddress( + events.map((event) => event.args.stToken), + { deployer, events: "DeployedGovernedStakingToken" }, + ); +} + +function getSingleDeployedAddress( + addresses: readonly Address[], + source: { readonly deployer: Address; readonly events: string }, +): Address { + const deployed = [...new Set(addresses.map((address) => getAddress(address)))]; + + if (deployed.length === 0) { + throw new SdkError({ + code: "RECORD_NOT_FOUND", + message: `Could not find ${source.events} emitted by ${source.deployer} in transaction logs`, + meta: { deployer: source.deployer }, + }); + } + if (deployed.length > 1) { + throw new SdkError({ + code: "INVALID_RESPONSE", + message: `${source.deployer} emitted ${source.events} for more than one address`, + meta: { deployer: source.deployer, deployed }, + }); } - throw new SdkError({ - code: "RECORD_NOT_FOUND", - message: "Could not find DeployedGovernedStakingToken event in transaction logs", - }); + return deployed[0]!; } function normalizeBasicDetails(details: IndexDtfDeployBasicDetails): IndexDtfDeployBasicDetails { @@ -399,6 +586,73 @@ function normalizeAdditionalDetails(details: IndexDtfDeployAdditionalDetails): I }; } +function normalizeAdditionalDetailsV6(details: IndexDtfDeployAdditionalDetailsV6) { + if (details.maxAuctionLength < MIN_AUCTION_LENGTH || details.maxAuctionLength > MAX_AUCTION_LENGTH) { + throw new SdkError({ + code: "INVALID_INPUT", + message: "maxAuctionLength must be between 120 and 604800 seconds", + meta: { maxAuctionLength: details.maxAuctionLength }, + }); + } + if (details.tvlFee < 0n || details.mintFee < 0n) { + throw new SdkError({ code: "INVALID_INPUT", message: "fees must be non-negative" }); + } + if (details.selfFee < 0n || details.selfFee > MAX_SELF_FEE) { + throw new SdkError({ + code: "INVALID_INPUT", + message: "selfFee must be a D18 fraction between 0 and 1e18", + meta: { selfFee: details.selfFee }, + }); + } + + const tables = { + recipients: sortIndexDtfFeeRecipients(details.feeRecipients), + immutableRecipients: sortIndexDtfFeeRecipients(details.immutableFeeRecipients), + }; + assertIndexDtfFeeRecipientTables(tables); + + return { + maxAuctionLength: details.maxAuctionLength, + feeRecipients: tables.recipients, + immutableFeeRecipients: tables.immutableRecipients, + tvlFee: details.tvlFee, + mintFee: details.mintFee, + folioFeeForSelf: details.selfFee, + mandate: details.mandate, + }; +} + +function normalizeOptimisticGovernanceParams(params: IndexDtfDeployOptimisticGovernanceParams) { + return { + optimisticParams: { + vetoDelay: toUintNumber(params.optimistic.vetoDelay, "vetoDelay"), + vetoPeriod: toUintNumber(params.optimistic.vetoPeriod, "vetoPeriod"), + vetoThreshold: toUint(params.optimistic.vetoThreshold, "vetoThreshold"), + }, + standardParams: { + votingDelay: toUintNumber(params.standard.votingDelay, "votingDelay"), + votingPeriod: toUintNumber(params.standard.votingPeriod, "votingPeriod"), + voteExtension: toUintNumber(params.standard.voteExtension, "voteExtension"), + proposalThreshold: toUint(params.standard.proposalThreshold, "proposalThreshold"), + quorumNumerator: toUint(params.standard.quorumNumerator, "quorumNumerator"), + }, + optimisticSelectors: params.optimisticSelectors.map((selector) => { + if (!/^0x[0-9a-fA-F]{8}$/.test(selector)) { + throw new SdkError({ + code: "INVALID_INPUT", + message: "optimisticSelectors must be 4-byte function selectors", + meta: { selector }, + }); + } + return selector; + }), + optimisticProposers: normalizeAddresses(params.optimisticProposers), + additionalGuardians: normalizeAddresses(params.additionalGuardians), + timelockDelay: toUint(params.timelockDelay, "timelockDelay"), + proposalThrottleCapacity: toUint(params.proposalThrottleCapacity, "proposalThrottleCapacity"), + }; +} + function normalizeFlags(flags: IndexDtfDeployFlags): IndexDtfDeployFlags { return { trustedFillerEnabled: flags.trustedFillerEnabled, diff --git a/packages/sdk/src/index-dtf/dtf/basket/index.ts b/packages/sdk/src/index-dtf/dtf/basket/index.ts index 300cd4d..8543bfd 100644 --- a/packages/sdk/src/index-dtf/dtf/basket/index.ts +++ b/packages/sdk/src/index-dtf/dtf/basket/index.ts @@ -20,7 +20,9 @@ export type { IndexDtfBasketTokenInput, IndexDtfBasketUnitsInput, IndexDtfInitialBasket, + IndexDtfStartRebalanceVersion, StartRebalanceArgsV5, + StartRebalanceArgsV6, } from "@/index-dtf/dtf/basket/types"; export { buildInitialBasket, diff --git a/packages/sdk/src/index-dtf/dtf/basket/rebalance-args.test.ts b/packages/sdk/src/index-dtf/dtf/basket/rebalance-args.test.ts new file mode 100644 index 0000000..21a518b --- /dev/null +++ b/packages/sdk/src/index-dtf/dtf/basket/rebalance-args.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import type { BuildIndexDtfStartRebalanceArgsParams } from "@/index-dtf/dtf/basket/types"; + +import { buildStartRebalanceArgs } from "@/index-dtf/dtf/basket/rebalance-args"; + +// The pure builder has no client to resolve a version from, so an unsupported +// or missing version must fail before any math runs (v4 is Register-local). +describe("buildStartRebalanceArgs version contract", () => { + it.each([undefined, "4.0.0", "5.1.0", "7.0.0"])("rejects version %s with a typed error", (version) => { + expect(() => buildStartRebalanceArgs({ version } as unknown as BuildIndexDtfStartRebalanceArgsParams)).toThrow( + /Unsupported Index DTF version/, + ); + }); +}); diff --git a/packages/sdk/src/index-dtf/dtf/basket/rebalance-args.ts b/packages/sdk/src/index-dtf/dtf/basket/rebalance-args.ts index cfdd065..d2a7c37 100644 --- a/packages/sdk/src/index-dtf/dtf/basket/rebalance-args.ts +++ b/packages/sdk/src/index-dtf/dtf/basket/rebalance-args.ts @@ -1,12 +1,20 @@ import { FolioVersion, getStartRebalance } from "@reserve-protocol/dtf-rebalance-lib"; -import type { BuildIndexDtfStartRebalanceArgsParams, StartRebalanceArgsV5 } from "@/index-dtf/dtf/basket/types"; +import type { + BuildIndexDtfStartRebalanceArgsParams, + StartRebalanceArgsV5, + StartRebalanceArgsV6, +} from "@/index-dtf/dtf/basket/types"; import { getBasketShares } from "@/index-dtf/dtf/basket/math"; import { assertPositiveNumber, validateBasketTokens } from "@/index-dtf/dtf/basket/validation"; +import { assertIndexDtfWriteVersion } from "@/index-dtf/governance/propose/calls"; import { SdkError } from "@/lib/errors"; -export function buildStartRebalanceArgs(params: BuildIndexDtfStartRebalanceArgsParams): StartRebalanceArgsV5 { +export function buildStartRebalanceArgs( + params: BuildIndexDtfStartRebalanceArgsParams, +): StartRebalanceArgsV5 | StartRebalanceArgsV6 { + assertIndexDtfWriteVersion(params.version); if (params.supply <= 0n) { throw new SdkError({ code: "INVALID_INPUT", @@ -22,6 +30,12 @@ export function buildStartRebalanceArgs(params: BuildIndexDtfStartRebalanceArgsP } validateBasketTokens(params.tokens); + if (params.tokens.length < 2) { + throw new SdkError({ + code: "INVALID_INPUT", + message: "Rebalance must include at least two tokens", + }); + } const targetShares = getBasketShares(params.tokens, params.basket); for (const priceError of params.priceErrors) { @@ -38,7 +52,7 @@ export function buildStartRebalanceArgs(params: BuildIndexDtfStartRebalanceArgsP } return getStartRebalance( - FolioVersion.V5, + params.version === "6.0.0" ? FolioVersion.V6 : FolioVersion.V5, params.supply, params.tokens.map((token) => token.address), [...params.balances], @@ -49,5 +63,5 @@ export function buildStartRebalanceArgs(params: BuildIndexDtfStartRebalanceArgsP [...params.maxAuctionSizesUsd], params.weightControl, params.deferWeights ?? false, - ) as StartRebalanceArgsV5; + ) as StartRebalanceArgsV5 | StartRebalanceArgsV6; } diff --git a/packages/sdk/src/index-dtf/dtf/basket/start-rebalance.ts b/packages/sdk/src/index-dtf/dtf/basket/start-rebalance.ts index b3edc6f..576d757 100644 --- a/packages/sdk/src/index-dtf/dtf/basket/start-rebalance.ts +++ b/packages/sdk/src/index-dtf/dtf/basket/start-rebalance.ts @@ -18,7 +18,8 @@ import { assertUniqueAddresses, assertValidBasketAddresses, } from "@/index-dtf/dtf/basket/validation"; -import { getTotalSupply } from "@/index-dtf/dtf/index"; +import { getTotalSupply, getVersion } from "@/index-dtf/dtf/index"; +import { assertIndexDtfWriteVersion } from "@/index-dtf/governance/propose/calls"; import { SdkError } from "@/lib/errors"; export async function buildIndexDtfStartRebalance( @@ -26,6 +27,9 @@ export async function buildIndexDtfStartRebalance( params: BuildIndexDtfStartRebalanceParams, ): Promise { const address = getAddress(params.address); + // No silent default: an omitted version is read from the proxy and must be a supported one. + const version = params.version ?? (await getVersion(client, params)); + assertIndexDtfWriteVersion(version); const inputTokens = params.basket.tokens.map((token) => ({ ...token, address: getAddress(token.address) })); assertUniqueAddresses(inputTokens.map((token) => token.address)); assertValidBasketAddresses(inputTokens.map((token) => token.address)); @@ -69,6 +73,7 @@ export async function buildIndexDtfStartRebalance( return { address, chainId: params.chainId, + version, tokens: pricedTokens, assets: pricedTokens.map((token, index) => ({ token, @@ -81,6 +86,7 @@ export async function buildIndexDtfStartRebalance( weightControl, deferWeights: params.deferWeights ?? false, startRebalanceArgs: buildStartRebalanceArgs({ + version, tokens: pricedTokens, supply, balances, diff --git a/packages/sdk/src/index-dtf/dtf/basket/types.ts b/packages/sdk/src/index-dtf/dtf/basket/types.ts index f6f9cff..22d25cc 100644 --- a/packages/sdk/src/index-dtf/dtf/basket/types.ts +++ b/packages/sdk/src/index-dtf/dtf/basket/types.ts @@ -114,12 +114,17 @@ export type BuildIndexDtfInitialBasketParams = { readonly initialShares: bigint; }; +export type IndexDtfStartRebalanceVersion = "5.0.0" | "6.0.0"; + export type StartRebalanceArgsV5 = { readonly tokens: readonly TokenRebalanceParams[]; readonly limits: RebalanceLimits; }; +export type StartRebalanceArgsV6 = StartRebalanceArgsV5; + export type BuildIndexDtfStartRebalanceArgsParams = { + readonly version: IndexDtfStartRebalanceVersion; readonly tokens: readonly IndexDtfBasketToken[]; readonly supply: bigint; readonly balances: readonly bigint[]; @@ -131,6 +136,7 @@ export type BuildIndexDtfStartRebalanceArgsParams = { }; export type BuildIndexDtfStartRebalanceParams = DtfParams & { + readonly version?: IndexDtfStartRebalanceVersion; readonly basket: IndexDtfBasketInput; readonly dtf?: IndexDtf; readonly supply?: bigint; @@ -156,10 +162,11 @@ export type BuiltIndexDtfStartRebalanceAsset = { export type BuiltIndexDtfStartRebalance = { readonly address: Address; readonly chainId: DtfParams["chainId"]; + readonly version: IndexDtfStartRebalanceVersion; readonly tokens: readonly IndexDtfBasketToken[]; readonly assets: readonly BuiltIndexDtfStartRebalanceAsset[]; readonly supply: bigint; readonly weightControl: boolean; readonly deferWeights: boolean; - readonly startRebalanceArgs: StartRebalanceArgsV5; + readonly startRebalanceArgs: StartRebalanceArgsV5 | StartRebalanceArgsV6; }; diff --git a/packages/sdk/src/index-dtf/dtf/folio-v6.test.ts b/packages/sdk/src/index-dtf/dtf/folio-v6.test.ts new file mode 100644 index 0000000..790e191 --- /dev/null +++ b/packages/sdk/src/index-dtf/dtf/folio-v6.test.ts @@ -0,0 +1,219 @@ +import { + ContractFunctionExecutionError, + ContractFunctionZeroDataError, + HttpRequestError, + getAddress, + type Address, +} from "viem"; +import { describe, expect, it, vi } from "vitest"; + +import type { DtfClient } from "@/client"; + +import { folioV6Abi } from "@/index-dtf/abis/folio-v6.generated"; +import { + getIndexDtfImmutableFeeRecipients, + getIndexDtfIsTokenAllowlisted, + getIndexDtfMaxAuctionLength, + getIndexDtfSelfFee, + getIndexDtfTradeAllowlist, +} from "@/index-dtf/dtf/folio-v6"; + +const DTF = getAddress("0x4da9a0f397db1397902070f93a4d6ddbc0e0e6e8"); +const TOKEN = getAddress("0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"); +const LOWER_DTF = DTF.toLowerCase() as Address; +const LOWER_TOKEN = TOKEN.toLowerCase() as Address; +const RECIPIENT_A = "0x000000000000000000000000000000000000000a"; +const RECIPIENT_B = "0x000000000000000000000000000000000000000b"; + +type MulticallRequest = { + readonly contracts: readonly { address: string; functionName: string; args?: readonly unknown[] }[]; + readonly allowFailure: boolean; + readonly blockNumber?: bigint; +}; + +function createClient(handlers: { + readonly readContract?: (request: { functionName: string; args?: readonly unknown[] }) => unknown; + readonly multicall?: (request: MulticallRequest) => unknown; + readonly probeFails?: boolean; + readonly boundaryTransportFails?: boolean; +}) { + const readContract = vi.fn(async (request: { functionName: string; args?: readonly unknown[] }) => + handlers.readContract?.(request), + ); + const multicall = vi.fn(async (request: MulticallRequest) => handlers.multicall?.(request)); + const getBlockNumber = vi.fn(async () => 777n); + const probe = vi.fn(async (request: { functionName: string; args?: readonly unknown[]; blockNumber?: bigint }) => { + if (handlers.probeFails) throw new Error("not v6"); + if (request.functionName === "folioFeeForSelf") return 0n; + // The boundary read: a contract revert past the table end, or a transport failure when asked to. + if (handlers.boundaryTransportFails) { + throw new ContractFunctionExecutionError( + new HttpRequestError({ url: "http://127.0.0.1", details: "connection reset" }), + { + abi: folioV6Abi, + functionName: request.functionName, + args: request.args ?? [], + }, + ); + } + throw new ContractFunctionExecutionError( + new ContractFunctionZeroDataError({ functionName: request.functionName }), + { + abi: folioV6Abi, + functionName: request.functionName, + args: request.args ?? [], + }, + ); + }); + const client = { + viem: { readContract, getPublicClient: vi.fn(() => ({ multicall, getBlockNumber, readContract: probe })) }, + } as unknown as DtfClient; + + return { client, readContract, multicall, getBlockNumber, probe }; +} + +function immutableTable(table: readonly (readonly [string, bigint])[]) { + return ({ contracts }: MulticallRequest) => + contracts.map(({ args }) => { + const entry = table[Number(args?.[0])]; + + return entry ? { status: "success", result: entry } : { status: "failure", error: new Error("revert") }; + }); +} + +describe("Folio 6.0 state reads", () => { + it("reads maxAuctionLength with the v6 ABI at the requested block", async () => { + const { client, readContract } = createClient({ readContract: () => 1800n }); + + await expect( + getIndexDtfMaxAuctionLength(client, { address: LOWER_DTF, chainId: 56, blockNumber: 9n }), + ).resolves.toBe(1800n); + expect(readContract).toHaveBeenCalledWith({ + address: DTF, + abi: folioV6Abi, + functionName: "maxAuctionLength", + chainId: 56, + blockNumber: 9n, + }); + }); + + it("maps folioFeeForSelf as a D18 amount at the requested block", async () => { + const { client, readContract } = createClient({ readContract: () => 50_000_000_000_000_000n }); + + await expect(getIndexDtfSelfFee(client, { address: DTF, chainId: 1, blockNumber: 10n })).resolves.toEqual({ + raw: 50_000_000_000_000_000n, + formatted: "0.05", + }); + expect(readContract).toHaveBeenCalledWith({ + address: DTF, + abi: folioV6Abi, + functionName: "folioFeeForSelf", + chainId: 1, + blockNumber: 10n, + }); + }); + + it("reads the immutable table in one pinned multicall and stops at the first reverting index", async () => { + const { client, multicall, getBlockNumber, probe } = createClient({ + multicall: immutableTable([ + [RECIPIENT_A, 300_000_000_000_000_000n], + [RECIPIENT_B, 200_000_000_000_000_000n], + ]), + }); + + await expect(getIndexDtfImmutableFeeRecipients(client, { address: LOWER_DTF, chainId: 8453 })).resolves.toEqual([ + { recipient: "0x000000000000000000000000000000000000000A", portion: 300_000_000_000_000_000n }, + { recipient: "0x000000000000000000000000000000000000000b", portion: 200_000_000_000_000_000n }, + ]); + expect(multicall).toHaveBeenCalledOnce(); + expect(getBlockNumber).toHaveBeenCalledOnce(); + const request = multicall.mock.calls[0]![0]; + expect(request.blockNumber).toBe(777n); + expect(request.contracts.map(({ functionName }) => functionName)).toEqual( + Array.from({ length: 64 }, () => "immutableFeeRecipients"), + ); + expect(request.contracts.map(({ args }) => args?.[0])).toEqual(Array.from({ length: 64 }, (_, i) => BigInt(i))); + expect(request.contracts.every((contract) => contract.address === DTF)).toBe(true); + expect(request).toMatchObject({ batchSize: 0 }); + expect(getBlockNumber).toHaveBeenCalledWith({ cacheTime: 0 }); + expect(probe).toHaveBeenNthCalledWith(1, { + address: DTF, + abi: folioV6Abi, + functionName: "folioFeeForSelf", + blockNumber: 777n, + }); + expect(probe).toHaveBeenNthCalledWith(2, { + address: DTF, + abi: folioV6Abi, + functionName: "immutableFeeRecipients", + args: [2n], + blockNumber: 777n, + }); + }); + + it("returns a full 64-entry table and keeps an explicit block", async () => { + const { client, multicall, getBlockNumber } = createClient({ + multicall: immutableTable(Array.from({ length: 64 }, () => [RECIPIENT_A, 1n] as const)), + }); + + await expect( + getIndexDtfImmutableFeeRecipients(client, { address: DTF, chainId: 1, blockNumber: 5n }), + ).resolves.toHaveLength(64); + expect(getBlockNumber).not.toHaveBeenCalled(); + expect(multicall.mock.calls[0]![0].blockNumber).toBe(5n); + }); + + it("returns an empty table when the probe succeeds and index 0 reverts", async () => { + const { client } = createClient({ multicall: immutableTable([]) }); + + await expect(getIndexDtfImmutableFeeRecipients(client, { address: DTF, chainId: 1 })).resolves.toEqual([]); + }); + + it("refuses a short table when the boundary read fails as a request instead of a revert", async () => { + const { client } = createClient({ multicall: immutableTable([]), boundaryTransportFails: true }); + + await expect(getIndexDtfImmutableFeeRecipients(client, { address: DTF, chainId: 1 })).rejects.toThrow( + "connection reset", + ); + }); + + it("throws instead of reporting an empty table when the address is not a Folio 6.0", async () => { + const { client, multicall } = createClient({ probeFails: true, multicall: immutableTable([]) }); + + await expect(getIndexDtfImmutableFeeRecipients(client, { address: DTF, chainId: 1 })).rejects.toThrow("not v6"); + expect(multicall).not.toHaveBeenCalled(); + }); + + it("reads allowlist enforcement and tokens in one multicall", async () => { + const { client, multicall } = createClient({ multicall: () => [true, [LOWER_TOKEN]] }); + + await expect(getIndexDtfTradeAllowlist(client, { address: DTF, chainId: 56, blockNumber: 5n })).resolves.toEqual({ + enabled: true, + tokens: [TOKEN], + }); + expect(multicall.mock.calls[0]?.[0]).toMatchObject({ + allowFailure: false, + blockNumber: 5n, + contracts: [ + { address: DTF, functionName: "tradeAllowlistEnabled" }, + { address: DTF, functionName: "getTokenAllowlist" }, + ], + }); + }); + + it("reads isTokenAllowlisted for one token", async () => { + const { client, readContract } = createClient({ readContract: () => false }); + + await expect( + getIndexDtfIsTokenAllowlisted(client, { address: LOWER_DTF, chainId: 1, token: LOWER_TOKEN }), + ).resolves.toBe(false); + expect(readContract).toHaveBeenCalledWith({ + address: DTF, + abi: folioV6Abi, + functionName: "isTokenAllowlisted", + args: [TOKEN], + chainId: 1, + blockNumber: undefined, + }); + }); +}); diff --git a/packages/sdk/src/index-dtf/dtf/folio-v6.ts b/packages/sdk/src/index-dtf/dtf/folio-v6.ts new file mode 100644 index 0000000..c2ea5cb --- /dev/null +++ b/packages/sdk/src/index-dtf/dtf/folio-v6.ts @@ -0,0 +1,150 @@ +import { + BaseError, + ContractFunctionRevertedError, + ContractFunctionZeroDataError, + getAddress, + type Address, +} from "viem"; + +import type { DtfClient } from "@/client"; +import type { IndexDtfFeeRecipient } from "@/index-dtf/fee-recipients"; +import type { Amount, DtfParams } from "@/types/common"; + +import { folioV6Abi } from "@/index-dtf/abis/folio-v6.generated"; +import { INDEX_DTF_MAX_FEE_RECIPIENTS } from "@/index-dtf/fee-recipients"; +import { SdkError } from "@/lib/errors"; +import { mapAmount } from "@/lib/utils"; + +export type IndexDtfTradeAllowlist = { + readonly enabled: boolean; + readonly tokens: readonly Address[]; +}; + +export type GetIndexDtfIsTokenAllowlistedParams = DtfParams & { + readonly token: Address; +}; + +/** + * Reads Folio 6.0's `maxAuctionLength()`: the per-auction length ceiling and the + * only valid `openAuction` length under PriceControl NONE. Reverts on v5 proxies. + */ +export async function getIndexDtfMaxAuctionLength(client: DtfClient, params: DtfParams): Promise { + return client.viem.readContract({ + address: getAddress(params.address), + abi: folioV6Abi, + functionName: "maxAuctionLength", + chainId: params.chainId, + blockNumber: params.blockNumber, + }); +} + +/** Reads Folio 6.0's `folioFeeForSelf()`: the D18 fraction of non-DAO fees the Folio keeps for its holders. */ +export async function getIndexDtfSelfFee(client: DtfClient, params: DtfParams): Promise { + const raw = await client.viem.readContract({ + address: getAddress(params.address), + abi: folioV6Abi, + functionName: "folioFeeForSelf", + chainId: params.chainId, + blockNumber: params.blockNumber, + }); + + return mapAmount(raw, 18); +} + +/** + * Reads Folio 6.0's full immutable fee recipient table. `setFeeRecipients` must be given this exact + * table back, so it is read from RPC at one block rather than from the subgraph. + */ +export async function getIndexDtfImmutableFeeRecipients( + client: DtfClient, + params: DtfParams, +): Promise { + const address = getAddress(params.address); + const publicClient = client.viem.getPublicClient(params.chainId); + // viem caches getBlockNumber for a polling interval; a write mined a moment ago must be visible here. + const blockNumber = params.blockNumber ?? (await publicClient.getBlockNumber({ cacheTime: 0 })); + // The array getter reverts past the end of the table, which is indistinguishable from a v5 proxy or a + // non-Folio address, so a v6-only read at the same block must succeed before a short table means "short". + await publicClient.readContract({ address, abi: folioV6Abi, functionName: "folioFeeForSelf", blockNumber }); + const entries = await publicClient.multicall({ + allowFailure: true, + batchSize: 0, + blockNumber, + contracts: Array.from({ length: INDEX_DTF_MAX_FEE_RECIPIENTS }, (_, index) => ({ + address, + abi: folioV6Abi, + functionName: "immutableFeeRecipients" as const, + args: [BigInt(index)] as const, + })), + }); + + const recipients: IndexDtfFeeRecipient[] = []; + for (const entry of entries) { + if (entry.status !== "success") { + break; + } + const [recipient, portion] = entry.result; + recipients.push({ recipient: getAddress(recipient), portion }); + } + + // A transport failure inside the multicall also reads as "end of table"; the boundary index must revert + // as a contract call, not fail as a request, before a short table is trusted. + if (recipients.length < INDEX_DTF_MAX_FEE_RECIPIENTS) { + try { + await publicClient.readContract({ + address, + abi: folioV6Abi, + functionName: "immutableFeeRecipients", + args: [BigInt(recipients.length)], + blockNumber, + }); + throw new SdkError({ + code: "REQUEST_FAILED", + message: "immutable fee recipient table read was truncated; retry", + meta: { address, blockNumber, length: recipients.length }, + }); + } catch (error) { + // viem wraps transport failures in the same execution error; only a revert or empty return is end-of-table. + const reverted = + error instanceof BaseError && + error.walk( + (cause) => cause instanceof ContractFunctionRevertedError || cause instanceof ContractFunctionZeroDataError, + ) !== null; + if (!reverted) { + throw error; + } + } + } + + return recipients; +} + +/** Reads Folio 6.0's trade allowlist state: whether enforcement is on and the allowlisted tokens. */ +export async function getIndexDtfTradeAllowlist(client: DtfClient, params: DtfParams): Promise { + const address = getAddress(params.address); + const [enabled, tokens] = await client.viem.getPublicClient(params.chainId).multicall({ + allowFailure: false, + blockNumber: params.blockNumber, + contracts: [ + { address, abi: folioV6Abi, functionName: "tradeAllowlistEnabled" }, + { address, abi: folioV6Abi, functionName: "getTokenAllowlist" }, + ], + }); + + return { enabled, tokens: tokens.map((token) => getAddress(token)) }; +} + +/** Reads Folio 6.0's `isTokenAllowlisted(token)`: membership only; with enforcement off every token is tradable. */ +export async function getIndexDtfIsTokenAllowlisted( + client: DtfClient, + params: GetIndexDtfIsTokenAllowlistedParams, +): Promise { + return client.viem.readContract({ + address: getAddress(params.address), + abi: folioV6Abi, + functionName: "isTokenAllowlisted", + args: [getAddress(params.token)], + chainId: params.chainId, + blockNumber: params.blockNumber, + }); +} diff --git a/packages/sdk/src/index-dtf/dtf/index.test.ts b/packages/sdk/src/index-dtf/dtf/index.test.ts index 7e01da5..0cee095 100644 --- a/packages/sdk/src/index-dtf/dtf/index.test.ts +++ b/packages/sdk/src/index-dtf/dtf/index.test.ts @@ -441,6 +441,87 @@ describe("Index DTF getters", () => { ); }); + it("maps a pre-6.0 DTF with empty v6 fee and allowlist defaults", () => { + const dtf = mapIndexDtf(createSubgraphDtf(), 1); + + expect(dtf.version).toBeUndefined(); + expect(dtf.fees.immutableRecipients).toEqual([]); + expect(dtf.fees.selfFee).toEqual({ raw: 0n, formatted: "0" }); + expect(dtf.rebalance.maxAuctionLength).toBeUndefined(); + expect(dtf.rebalance.tradeAllowlist).toBeUndefined(); + }); + + it("maps a grafted DTF whose 1.11 fields are null to the pre-6.0 defaults", () => { + const dtf = mapIndexDtf( + { + ...createSubgraphDtf(), + version: null, + maxAuctionLength: null, + tradeAllowlistEnabled: null, + tradeTokenAllowlist: null, + immutableFeeRecipients: null, + folioFeeForSelf: null, + selfRevenue: null, + totalRevenue: "30", + protocolRevenue: "10", + governanceRevenue: "15", + externalRevenue: "5", + }, + 1, + ); + + expect(dtf.version).toBeUndefined(); + expect(dtf.rebalance.maxAuctionLength).toBeUndefined(); + expect(dtf.rebalance.tradeAllowlist).toBeUndefined(); + expect(dtf.fees.immutableRecipients).toEqual([]); + expect(dtf.fees.selfFee).toEqual({ raw: 0n, formatted: "0" }); + expect(dtf.financials).toEqual({ + totalRevenue: 30, + protocolRevenue: 10, + governanceRevenue: 15, + externalRevenue: 5, + selfRevenue: 0, + }); + }); + + it("leaves the allowlist out when the subgraph has no token list for an enabled allowlist", () => { + const dtf = mapIndexDtf({ ...createSubgraphDtf(), tradeAllowlistEnabled: true, tradeTokenAllowlist: null }, 8453); + + expect(dtf.rebalance.tradeAllowlist).toBeUndefined(); + }); + + it("maps Folio 6.0 subgraph fields into fees and rebalance config", () => { + const dtf = mapIndexDtf( + { + ...createSubgraphDtf(), + version: "6.0.0", + maxAuctionLength: "1800", + immutableFeeRecipients: "0x000000000000000000000000000000000000000a:400000000000000000", + folioFeeForSelf: "50000000000000000", + tradeAllowlistEnabled: true, + tradeTokenAllowlist: ["0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"], + totalRevenue: "40", + protocolRevenue: "10", + governanceRevenue: "15", + externalRevenue: "5", + selfRevenue: "10", + }, + 8453, + ); + + expect(dtf.version).toBe("6.0.0"); + expect(dtf.financials).toMatchObject({ totalRevenue: 40, externalRevenue: 5, selfRevenue: 10 }); + expect(dtf.rebalance.maxAuctionLength).toBe(1800); + expect(dtf.rebalance.tradeAllowlist).toEqual({ + enabled: true, + tokens: [getAddress("0x833589fcd6edb6e08f4c7c32d4f71b54bda02913")], + }); + expect(dtf.fees.immutableRecipients).toEqual([ + { address: getAddress("0x000000000000000000000000000000000000000a"), percentage: "40" }, + ]); + expect(dtf.fees.selfFee).toEqual({ raw: 50_000_000_000_000_000n, formatted: "0.05" }); + }); + it("maps governance proposal thresholds from D18 fractions to percentages", () => { const dtf = mapIndexDtf(createSubgraphDtf(), 1); const authority = dtf.governance.admin.primary; diff --git a/packages/sdk/src/index-dtf/dtf/index.ts b/packages/sdk/src/index-dtf/dtf/index.ts index 272ed24..6caa79e 100644 --- a/packages/sdk/src/index-dtf/dtf/index.ts +++ b/packages/sdk/src/index-dtf/dtf/index.ts @@ -23,7 +23,7 @@ import type { IndexDtfTotalAssets, } from "@/types/index-dtf"; -import dtfAbi from "@/index-dtf/abis/dtf-index-abi-v6"; +import { folioV6Abi as dtfAbi } from "@/index-dtf/abis/folio-v6.generated"; import { mapIndexDtf, mapIndexDtfBasketSnapshot, diff --git a/packages/sdk/src/index-dtf/dtf/issuance-calls.test.ts b/packages/sdk/src/index-dtf/dtf/issuance-calls.test.ts index 38e0c8c..125b0f4 100644 --- a/packages/sdk/src/index-dtf/dtf/issuance-calls.test.ts +++ b/packages/sdk/src/index-dtf/dtf/issuance-calls.test.ts @@ -1,14 +1,139 @@ +import { decodeFunctionData, erc20Abi } from "viem"; import { describe, expect, it } from "vitest"; -import { getIndexDtfRedeemMinAmounts } from "@/index-dtf/dtf/issuance-calls"; +import { dtfIndexAbi } from "@/index-dtf/abis/dtf-index-abi"; +import { + getIndexDtfRedeemMinAmounts, + prepareIndexDtfMint, + prepareIndexDtfMintPlan, + prepareIndexDtfRedeem, +} from "@/index-dtf/dtf/issuance-calls"; +import { SdkError } from "@/lib/errors"; + +const DTF = "0x0000000000000000000000000000000000000001"; +const RECEIVER = "0x0000000000000000000000000000000000000002"; +const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; +const DAI = "0x6B175474E89094C44Da98b954EedeAC495271d0F"; describe("Index DTF issuance call helpers", () => { - it("applies redeem slippage with named params", () => { + it("encodes mint shares, receiver and a distinct nonzero minimum output", () => { + const call = prepareIndexDtfMint({ + address: DTF, + chainId: 8453, + version: "5.0.0", + shares: 1000n, + receiver: RECEIVER, + minSharesOut: 987n, + }); + + expect(call).toMatchObject({ to: DTF, chainId: 8453, value: 0n }); + expect(decodeFunctionData({ abi: dtfIndexAbi, data: call.data })).toEqual({ + functionName: "mint", + args: [1000n, RECEIVER, 987n], + }); + }); + + it("preserves redeem token order and per-token minimum outputs", () => { + const call = prepareIndexDtfRedeem({ + address: DTF, + chainId: 1, + version: "5.0.0", + shares: 10n ** 18n, + receiver: RECEIVER, + assets: [USDC, DAI], + minAmountsOut: [990_000n, 2n * 10n ** 18n], + }); + + expect(call).toMatchObject({ to: DTF, chainId: 1, value: 0n }); + expect(decodeFunctionData({ abi: dtfIndexAbi, data: call.data })).toEqual({ + functionName: "redeem", + args: [10n ** 18n, RECEIVER, [USDC, DAI], [990_000n, 2n * 10n ** 18n]], + }); + }); + + it("pairs each mint approval with its collateral and preserves the final mint", () => { + const plan = prepareIndexDtfMintPlan({ + address: DTF, + chainId: 8453, + version: "5.0.0", + shares: 1000n, + receiver: RECEIVER, + minSharesOut: 987n, + approvals: [ + { token: USDC, amount: 3_000_000n }, + { token: DAI, amount: 2n * 10n ** 18n }, + ], + }); + + expect(plan.type).toBe("approval-required"); + if (plan.type !== "approval-required") throw new Error("expected collateral approvals"); expect( - getIndexDtfRedeemMinAmounts({ - amounts: [100n, 200n], - slippageBps: 100, - }), - ).toEqual([99n, 198n]); + plan.approvals.map(({ to, chainId, value, data }) => ({ + to, + chainId, + value, + decoded: decodeFunctionData({ abi: erc20Abi, data }), + })), + ).toEqual([ + { to: USDC, chainId: 8453, value: 0n, decoded: { functionName: "approve", args: [DTF, 3_000_000n] } }, + { to: DAI, chainId: 8453, value: 0n, decoded: { functionName: "approve", args: [DTF, 2n * 10n ** 18n] } }, + ]); + expect(plan.call).toMatchObject({ to: DTF, chainId: 8453, value: 0n }); + expect(decodeFunctionData({ abi: dtfIndexAbi, data: plan.call.data })).toEqual({ + functionName: "mint", + args: [1000n, RECEIVER, 987n], + }); + }); + + it("returns the mint directly when no approvals are required", () => { + const plan = prepareIndexDtfMintPlan({ + address: DTF, + chainId: 1, + version: "5.0.0", + shares: 1000n, + receiver: RECEIVER, + minSharesOut: 987n, + }); + expect(plan.type).toBe("call"); + expect(decodeFunctionData({ abi: dtfIndexAbi, data: plan.call.data })).toEqual({ + functionName: "mint", + args: [1000n, RECEIVER, 987n], + }); + }); + + it.each([ + { slippageBps: 0, expected: [101n, 999n] }, + { slippageBps: 100, expected: [99n, 989n] }, + { slippageBps: 10_000, expected: [0n, 0n] }, + ])("floors redeem minimums with $slippageBps bps slippage", ({ slippageBps, expected }) => { + expect(getIndexDtfRedeemMinAmounts({ amounts: [101n, 999n], slippageBps })).toEqual(expected); + }); + + it("keeps redeem amounts precise above the safe integer range", () => { + expect(getIndexDtfRedeemMinAmounts({ amounts: [10n ** 24n + 123n], slippageBps: 1 })).toEqual([ + 999_900_000_000_000_000_000_122n, + ]); + }); + + it.each([-1, 10_001, 0.5, NaN])("rejects invalid slippage %s", (slippageBps) => { + expect(() => getIndexDtfRedeemMinAmounts({ amounts: [100n], slippageBps })).toThrow( + "slippageBps must be an integer between 0 and 10000", + ); + }); + + it("encodes identical mint bytes on 5.0.0 and 6.0.0 and rejects other versions", () => { + const input = { + address: DTF, + chainId: 8453, + shares: 10n, + receiver: "0x0000000000000000000000000000000000000002", + minSharesOut: 9n, + } as const; + expect(prepareIndexDtfMint({ ...input, version: "6.0.0" }).data).toBe( + prepareIndexDtfMint({ ...input, version: "5.0.0" }).data, + ); + const build = () => prepareIndexDtfMint({ ...input, version: "4.0.0" as unknown as "5.0.0" }); + expect(build).toThrow(SdkError); + expect(build).toThrow(expect.objectContaining({ code: "INVALID_INPUT", meta: { version: "4.0.0" } })); }); }); diff --git a/packages/sdk/src/index-dtf/dtf/issuance-calls.ts b/packages/sdk/src/index-dtf/dtf/issuance-calls.ts index 677a2ff..a42d61e 100644 --- a/packages/sdk/src/index-dtf/dtf/issuance-calls.ts +++ b/packages/sdk/src/index-dtf/dtf/issuance-calls.ts @@ -2,8 +2,9 @@ import type { Address } from "viem"; import type { SupportedChainId } from "@/config"; import type { ContractCallPlan } from "@/lib/contract-call"; +import type { IndexDtfCall } from "@/types/governance"; -import { dtfIndexAbi } from "@/index-dtf/abis/dtf-index-abi"; +import { getIndexDtfWriteAbi, type IndexDtfWriteVersion } from "@/index-dtf/write-version"; import { prepareContractCall, prepareErc20Approval } from "@/lib/contract-call"; import { SdkError } from "@/lib/errors"; @@ -13,6 +14,7 @@ type RedeemArgs = readonly [bigint, Address, readonly Address[], readonly bigint export type PrepareIndexDtfMintParams = { readonly address: Address; readonly chainId: SupportedChainId; + readonly version: IndexDtfWriteVersion; readonly shares: bigint; readonly receiver: Address; readonly minSharesOut: bigint; @@ -21,6 +23,7 @@ export type PrepareIndexDtfMintParams = { export type PrepareIndexDtfRedeemParams = { readonly address: Address; readonly chainId: SupportedChainId; + readonly version: IndexDtfWriteVersion; readonly shares: bigint; readonly receiver: Address; readonly assets: readonly Address[]; @@ -41,12 +44,12 @@ export type PrepareIndexDtfMintPlanParams = PrepareIndexDtfMintParams & { }[]; }; -/** Prepares a v5 `mint(shares, receiver, minSharesOut)` contract call. */ -export function prepareIndexDtfMint(params: PrepareIndexDtfMintParams) { +/** Prepares a `mint(shares, receiver, minSharesOut)` contract call (identical bytes on 5.0.0 and 6.0.0). */ +export function prepareIndexDtfMint(params: PrepareIndexDtfMintParams): IndexDtfCall { return prepareContractCall({ chainId: params.chainId, address: params.address, - abi: dtfIndexAbi, + abi: getIndexDtfWriteAbi(params.version), functionName: "mint", args: getMintArgs(params), }); @@ -69,12 +72,12 @@ export function prepareIndexDtfMintPlan( return approvals.length ? { type: "approval-required", approvals, call } : { type: "call", call }; } -/** Prepares a v5 `redeem(shares, receiver, assets, minAmountsOut)` contract call. */ -export function prepareIndexDtfRedeem(params: PrepareIndexDtfRedeemParams) { +/** Prepares a `redeem(shares, receiver, assets, minAmountsOut)` contract call (identical bytes on 5.0.0 and 6.0.0). */ +export function prepareIndexDtfRedeem(params: PrepareIndexDtfRedeemParams): IndexDtfCall { return prepareContractCall({ chainId: params.chainId, address: params.address, - abi: dtfIndexAbi, + abi: getIndexDtfWriteAbi(params.version), functionName: "redeem", args: getRedeemArgs(params), }); diff --git a/packages/sdk/src/index-dtf/dtf/mappers.ts b/packages/sdk/src/index-dtf/dtf/mappers.ts index 500b87e..0aa69f0 100644 --- a/packages/sdk/src/index-dtf/dtf/mappers.ts +++ b/packages/sdk/src/index-dtf/dtf/mappers.ts @@ -101,6 +101,11 @@ export function mapIndexDtf(dtf: SubgraphIndexDtf, chainId: SupportedChainId): I primary: getAddress(dtf.ownerAddress), all: dtf.admins.map((address) => getAddress(address)), legacy: dtf.legacyAdmins.map((address) => getAddress(address)), + legacyGovernances: (dtf.legacyAdminGovernances ?? []).map((governance) => ({ + governance: getAddress(governance.id), + voteLock: getAddress(governance.token.id), + ...(governance.token.governance ? { voteLockGovernance: getAddress(governance.token.governance.id) } : {}), + })), }, rebalance: { auctionApprovers: dtf.auctionApprovers.map((address) => getAddress(address)), @@ -146,9 +151,25 @@ export function mapIndexDtf(dtf: SubgraphIndexDtf, chainId: SupportedChainId): I }, } : {}), + ...(dtf.version ? { version: dtf.version } : {}), rebalance: { auctionDelay: Number(dtf.auctionDelay), auctionLength: Number(dtf.auctionLength), + ...(dtf.maxAuctionLength === null || dtf.maxAuctionLength === undefined + ? {} + : { maxAuctionLength: Number(dtf.maxAuctionLength) }), + // Both fields or neither: an enabled allowlist with an unknown token list must not read as "nothing tradable". + ...(dtf.tradeAllowlistEnabled === null || + dtf.tradeAllowlistEnabled === undefined || + dtf.tradeTokenAllowlist === null || + dtf.tradeTokenAllowlist === undefined + ? {} + : { + tradeAllowlist: { + enabled: dtf.tradeAllowlistEnabled, + tokens: dtf.tradeTokenAllowlist.map((token) => getAddress(token)), + }, + }), ...(dtf.bidsEnabled === null || dtf.bidsEnabled === undefined ? {} : { bidsEnabled: dtf.bidsEnabled }), ...(dtf.trustedFillerRegistry ? { trustedFillerRegistry: getAddress(dtf.trustedFillerRegistry) } : {}), ...(dtf.trustedFillerEnabled === null || dtf.trustedFillerEnabled === undefined @@ -162,15 +183,18 @@ export function mapIndexDtf(dtf: SubgraphIndexDtf, chainId: SupportedChainId): I tvlFee: mapAmount(dtf.tvlFee, 18), annualizedTvlFee: Number(mapAmount(dtf.annualizedTvlFee, 18).formatted), recipients: mapFeeRecipients(dtf.feeRecipients), + immutableRecipients: mapFeeRecipients(dtf.immutableFeeRecipients ?? ""), + selfFee: mapAmount(dtf.folioFeeForSelf ?? 0n, 18), }, - // NOTE: revenue fields are subgraph BigDecimal analytics values (already in - // human units), kept as display-class numbers on purpose. On-chain integer - // amounts always map through mapAmount to Amount instead. + // NOTE: revenue fields are the subgraph's raw fee-share totals (BigInt, 18 decimals), kept as display-class + // numbers on purpose; precision past 2^53 is not needed for these analytics. Exact on-chain amounts always map + // through mapAmount to Amount instead. financials: { totalRevenue: Number(dtf.totalRevenue), protocolRevenue: Number(dtf.protocolRevenue), governanceRevenue: Number(dtf.governanceRevenue), externalRevenue: Number(dtf.externalRevenue), + selfRevenue: Number(dtf.selfRevenue ?? 0), }, }; } diff --git a/packages/sdk/src/index-dtf/dtf/revenue.test.ts b/packages/sdk/src/index-dtf/dtf/revenue.test.ts new file mode 100644 index 0000000..016ef5c --- /dev/null +++ b/packages/sdk/src/index-dtf/dtf/revenue.test.ts @@ -0,0 +1,119 @@ +import { decodeFunctionData } from "viem"; +import { describe, expect, it } from "vitest"; + +import type { IndexDtfPlatformFee } from "@/types/index-dtf"; + +import { dtfIndexAbi } from "@/index-dtf/abis/dtf-index-abi"; +import { folioArtifactAbi } from "@/index-dtf/abis/folio-artifact"; +import { getEffectiveRevenueDistribution, prepareIndexDtfDistributeFees } from "@/index-dtf/dtf/revenue"; +import { SdkError } from "@/lib/errors"; + +const DTF = "0x0000000000000000000000000000000000000001"; +const ZERO_SELF_FEE = { raw: 0n, formatted: "0" }; + +describe("prepareIndexDtfDistributeFees", () => { + it("encodes distributeFees identically for 5.0.0 and 6.0.0 with the matching ABI", () => { + const v5 = prepareIndexDtfDistributeFees({ address: DTF, chainId: 8453, version: "5.0.0" }); + const v6 = prepareIndexDtfDistributeFees({ address: DTF, chainId: 8453, version: "6.0.0" }); + + expect(v6.data).toBe(v5.data); + expect({ to: v5.to, chainId: v5.chainId, value: v5.value }).toEqual({ to: DTF, chainId: 8453, value: 0n }); + expect(decodeFunctionData({ abi: dtfIndexAbi, data: v5.data })).toEqual({ + functionName: "distributeFees", + args: undefined, + }); + expect(v5.contract.abi).toBe(dtfIndexAbi); + expect(v6.contract.abi).toBe(folioArtifactAbi); + }); + + it("rejects versions the write contract does not admit", () => { + const build = () => + prepareIndexDtfDistributeFees({ address: DTF, chainId: 8453, version: "4.0.0" as unknown as "5.0.0" }); + + expect(build).toThrow(SdkError); + expect(build).toThrow(expect.objectContaining({ code: "INVALID_INPUT", meta: { version: "4.0.0" } })); + }); + + it("splits a 6.0 fee: DAO first, then the self fee, then the mutable and immutable tables", () => { + // DAO 50%, self fee 25%, mutable/immutable 60/40 → mutable 22.5%, immutable 15%, holders 12.5%. + const platformFee: IndexDtfPlatformFee = { + registry: DTF, + recipient: DTF, + numerator: 1n, + denominator: 2n, + floor: 0n, + percent: 50, + }; + const distribution = getEffectiveRevenueDistribution( + { + recipients: [{ address: DTF, percentage: "60" }], + immutableRecipients: [{ address: "0x0000000000000000000000000000000000000002", percentage: "40" }], + selfFee: { raw: 250_000_000_000_000_000n, formatted: "0.25" }, + }, + platformFee, + ); + + expect(distribution.platform.percentage).toBe("50"); + expect(distribution.holders.percentage).toBe("12.5"); + expect(distribution.recipients).toEqual([ + { address: DTF, configuredPercentage: "60", effectivePercentage: "22.5" }, + { address: "0x0000000000000000000000000000000000000002", configuredPercentage: "40", effectivePercentage: "15" }, + ]); + const total = [ + distribution.platform.percentage, + distribution.holders.percentage, + ...distribution.recipients.map((recipient) => recipient.effectivePercentage), + ].reduce((sum, percentage) => sum + Number(percentage), 0); + expect(total).toBe(100); + }); + + it("gives the recipients' pool to the DAO when both tables are empty, as distributeFees does", () => { + const platformFee: IndexDtfPlatformFee = { + registry: DTF, + recipient: DTF, + numerator: 1n, + denominator: 2n, + floor: 0n, + percent: 50, + }; + const distribution = getEffectiveRevenueDistribution( + { recipients: [], immutableRecipients: [], selfFee: { raw: 250_000_000_000_000_000n, formatted: "0.25" } }, + platformFee, + ); + + expect(distribution).toEqual({ + platform: { recipient: DTF, percentage: "87.5" }, + holders: { percentage: "12.5" }, + recipients: [], + }); + }); + + it("folds the 6.0 immutable table and self fee into the effective distribution", () => { + const platformFee: IndexDtfPlatformFee = { + registry: DTF, + recipient: DTF, + numerator: 1n, + denominator: 10n, + floor: 0n, + percent: 10, + }; + const v5 = getEffectiveRevenueDistribution( + { recipients: [{ address: DTF, percentage: "100" }], immutableRecipients: [], selfFee: ZERO_SELF_FEE }, + platformFee, + ); + expect(v5.holders.percentage).toBe("0"); + expect(v5.recipients).toEqual([{ address: DTF, configuredPercentage: "100", effectivePercentage: "90" }]); + + const v6 = getEffectiveRevenueDistribution( + { + recipients: [{ address: DTF, percentage: "60" }], + immutableRecipients: [{ address: "0x0000000000000000000000000000000000000002", percentage: "40" }], + selfFee: { raw: 50_000_000_000_000_000n, formatted: "0.05" }, + }, + platformFee, + ); + expect(v6.platform.percentage).toBe("10"); + expect(v6.holders.percentage).toBe("4.5"); + expect(v6.recipients.map((recipient) => recipient.effectivePercentage)).toEqual(["51.3", "34.2"]); + }); +}); diff --git a/packages/sdk/src/index-dtf/dtf/revenue.ts b/packages/sdk/src/index-dtf/dtf/revenue.ts index 03fc19b..675fcb5 100644 --- a/packages/sdk/src/index-dtf/dtf/revenue.ts +++ b/packages/sdk/src/index-dtf/dtf/revenue.ts @@ -2,12 +2,14 @@ import { getAddress, type Address } from "viem"; import type { DtfClient } from "@/client"; import type { Amount, Token } from "@/types/common"; +import type { IndexDtfCall } from "@/types/governance"; import type { Financials, IndexDtf, IndexDtfPlatformFee, PriceControl } from "@/types/index-dtf"; import { dtfIndexAbi } from "@/index-dtf/abis/dtf-index-abi"; import { dtfIndexStakingVaultAbi } from "@/index-dtf/abis/dtf-index-staking-vault"; import { getDtf, getPrice } from "@/index-dtf/dtf/index"; import { getIndexDtfPlatformFee } from "@/index-dtf/dtf/platform-fee"; +import { getIndexDtfWriteAbi, type IndexDtfWriteVersion } from "@/index-dtf/write-version"; import { prepareContractCall } from "@/lib/contract-call"; import { Decimal } from "@/lib/decimal"; import { getTokensData } from "@/lib/tokens"; @@ -27,6 +29,8 @@ export type IndexDtfRevenueDistribution = { readonly recipient: Address; readonly percentage: string; }; + /** Percentage of total fees kept for holders (Folio 6.0 self fee); "0" before 6.0. */ + readonly holders: { readonly percentage: string }; readonly recipients: readonly { readonly address: Address; readonly configuredPercentage: string; @@ -115,26 +119,36 @@ export async function getIndexDtfRevenue( return { financials: dtf.financials, feeRecipients: dtf.fees.recipients, - effectiveDistribution: getEffectiveRevenueDistribution(dtf.fees.recipients, platformFee), + effectiveDistribution: getEffectiveRevenueDistribution(dtf.fees, platformFee), pendingFeeShares, pendingFeeSharesUsd: new Decimal(pendingFeeShares.formatted).mul(price.price).toNumber(), platformFee, }; } +/** + * The configured fee split, in the order Folio pays it: the DAO fee first, then (6.0) `selfFee` of the rest kept for + * holders, then the mutable and immutable tables, whose portions sum to 100% together. With both tables empty, + * `distributeFees` pays the recipients' pool to the DAO as well. It uses the DAO's nominal share: when a Folio's TVL + * or mint fee is low enough that the DAO fee floor binds, the DAO takes more and everyone else proportionally less. + */ export function getEffectiveRevenueDistribution( - feeRecipients: IndexDtf["fees"]["recipients"], + fees: Pick, platformFee: IndexDtfPlatformFee, ): IndexDtfRevenueDistribution { const platformPercentage = new Decimal(platformFee.percent); - const recipientPool = new Decimal(100).minus(platformPercentage); + const nonDaoPool = new Decimal(100).minus(platformPercentage); + const holdersPercentage = nonDaoPool.mul(fees.selfFee.formatted); + const recipientPool = nonDaoPool.minus(holdersPercentage); + const recipients = [...fees.recipients, ...fees.immutableRecipients]; return { platform: { recipient: platformFee.recipient, - percentage: platformPercentage.toString(), + percentage: (recipients.length === 0 ? platformPercentage.plus(recipientPool) : platformPercentage).toString(), }, - recipients: feeRecipients.map((recipient) => ({ + holders: { percentage: holdersPercentage.toString() }, + recipients: recipients.map((recipient) => ({ address: recipient.address, configuredPercentage: recipient.percentage, effectivePercentage: new Decimal(recipient.percentage).mul(recipientPool).div(100).toString(), @@ -146,11 +160,12 @@ export function getEffectiveRevenueDistribution( export function prepareIndexDtfDistributeFees(params: { readonly address: Address; readonly chainId: IndexDtf["chainId"]; -}) { + readonly version: IndexDtfWriteVersion; +}): IndexDtfCall { return prepareContractCall({ chainId: params.chainId, address: params.address, - abi: dtfIndexAbi, + abi: getIndexDtfWriteAbi(params.version), functionName: "distributeFees", args: [] as const, }); diff --git a/packages/sdk/src/index-dtf/fee-recipients.test.ts b/packages/sdk/src/index-dtf/fee-recipients.test.ts new file mode 100644 index 0000000..0293ff5 --- /dev/null +++ b/packages/sdk/src/index-dtf/fee-recipients.test.ts @@ -0,0 +1,112 @@ +import { getAddress, parseEther, type Address } from "viem"; +import { describe, expect, it } from "vitest"; + +import { + assertIndexDtfFeeRecipientTables, + scaleIndexDtfFeeRecipients, + sortIndexDtfFeeRecipients, +} from "@/index-dtf/fee-recipients"; +import { SdkError } from "@/lib/errors"; + +const LOW: Address = "0x0000000000000000000000000000000000000002"; +const MID: Address = "0x00000000000000000000000000000000000000aa"; +const HIGH: Address = "0x00000000000000000000000000000000000000B0"; + +describe("Folio fee recipient tables", () => { + it("sorts numerically by address, not lexically", () => { + const sorted = sortIndexDtfFeeRecipients([ + { recipient: HIGH, portion: 1n }, + { recipient: MID.toLowerCase() as Address, portion: 2n }, + { recipient: LOW, portion: 3n }, + ]); + + expect(sorted.map((entry) => entry.recipient)).toEqual([LOW, MID, HIGH].map((address) => getAddress(address))); + }); + + it("scales a 100% table to a remainder and puts the residual on the last entry", () => { + const scaled = scaleIndexDtfFeeRecipients( + [ + { recipient: LOW, portion: parseEther("0.333333333333333333") }, + { recipient: MID, portion: parseEther("0.333333333333333333") }, + { recipient: HIGH, portion: parseEther("0.333333333333333334") }, + ], + parseEther("0.7"), + ); + + expect(scaled.reduce((sum, entry) => sum + entry.portion, 0n)).toBe(parseEther("0.7")); + expect(scaled[0]?.portion).toBe(233_333_333_333_333_333n); + expect(scaleIndexDtfFeeRecipients([], parseEther("0.7"))).toEqual([]); + }); + + it("rejects a table that does not total 100% before scaling", () => { + expect(() => + scaleIndexDtfFeeRecipients([{ recipient: LOW, portion: parseEther("0.5") }], parseEther("0.7")), + ).toThrow(expect.objectContaining({ code: "INVALID_INPUT" })); + }); + + it("rejects the Folio itself, the zero address and more than 64 entries", () => { + expect(() => + assertIndexDtfFeeRecipientTables({ + recipients: [ + { recipient: LOW, portion: parseEther("0.5") }, + { recipient: HIGH, portion: parseEther("0.5") }, + ], + immutableRecipients: [], + folio: HIGH, + }), + ).toThrow("not the Folio itself"); + expect(() => + assertIndexDtfFeeRecipientTables({ + recipients: [{ recipient: "0x0000000000000000000000000000000000000000", portion: parseEther("1") }], + immutableRecipients: [], + }), + ).toThrow(expect.objectContaining({ code: "INVALID_INPUT" })); + const many = Array.from({ length: 65 }, (_, index) => ({ + recipient: getAddress(`0x${(index + 1).toString(16).padStart(40, "0")}`), + portion: 1n, + })); + expect(() => assertIndexDtfFeeRecipientTables({ recipients: many, immutableRecipients: [] })).toThrow( + "cannot exceed 64", + ); + }); + + it("accepts sorted tables that total 100% and empty tables", () => { + expect(() => + assertIndexDtfFeeRecipientTables({ + recipients: [{ recipient: LOW, portion: parseEther("0.6") }], + immutableRecipients: [{ recipient: HIGH, portion: parseEther("0.4") }], + }), + ).not.toThrow(); + expect(() => assertIndexDtfFeeRecipientTables({ recipients: [], immutableRecipients: [] })).not.toThrow(); + }); + + it("rejects unsorted, zero-portion and mis-totalled tables with INVALID_INPUT", () => { + const cases = [ + { + recipients: [ + { recipient: HIGH, portion: 1n }, + { recipient: LOW, portion: parseEther("1") - 1n }, + ], + immutableRecipients: [], + }, + { + recipients: [ + { recipient: LOW, portion: 0n }, + { recipient: HIGH, portion: parseEther("1") }, + ], + immutableRecipients: [], + }, + { + recipients: [{ recipient: LOW, portion: parseEther("0.5") }], + immutableRecipients: [{ recipient: HIGH, portion: parseEther("0.4") }], + }, + ]; + + for (const tables of cases) { + expect(() => assertIndexDtfFeeRecipientTables(tables)).toThrow(SdkError); + expect(() => assertIndexDtfFeeRecipientTables(tables)).toThrow( + expect.objectContaining({ code: "INVALID_INPUT" }), + ); + } + }); +}); diff --git a/packages/sdk/src/index-dtf/fee-recipients.ts b/packages/sdk/src/index-dtf/fee-recipients.ts new file mode 100644 index 0000000..3823fa6 --- /dev/null +++ b/packages/sdk/src/index-dtf/fee-recipients.ts @@ -0,0 +1,110 @@ +import { getAddress, parseEther, type Address } from "viem"; + +import { SdkError } from "@/lib/errors"; + +export type IndexDtfFeeRecipient = { + readonly recipient: Address; + readonly portion: bigint; +}; + +/** Folio fee tables are D18 fractions that must total exactly 100%. */ +export const INDEX_DTF_FEE_RECIPIENT_TOTAL = parseEther("1"); +export const INDEX_DTF_MAX_FEE_RECIPIENTS = 64; + +/** Folio requires each table strictly ascending by recipient address. */ +export function sortIndexDtfFeeRecipients(recipients: readonly IndexDtfFeeRecipient[]): IndexDtfFeeRecipient[] { + return recipients + .map((recipient) => ({ recipient: getAddress(recipient.recipient), portion: recipient.portion })) + .sort((a, b) => + BigInt(a.recipient) < BigInt(b.recipient) ? -1 : BigInt(a.recipient) > BigInt(b.recipient) ? 1 : 0, + ); +} + +/** + * Rescales a table that sums to 100% so it sums to `total` instead, putting the rounding residual on the + * last entry. Used to fit a mutable table next to an immutable one on Folio 6.0. + */ +export function scaleIndexDtfFeeRecipients( + recipients: readonly IndexDtfFeeRecipient[], + total: bigint, +): IndexDtfFeeRecipient[] { + if (recipients.length === 0) { + return []; + } + const inputTotal = recipients.reduce((sum, recipient) => sum + recipient.portion, 0n); + if (inputTotal !== INDEX_DTF_FEE_RECIPIENT_TOTAL) { + throw new SdkError({ + code: "INVALID_INPUT", + message: "fee recipient portions must total 100% before scaling", + meta: { total: inputTotal }, + }); + } + + const scaled = recipients.map((recipient) => ({ + recipient: recipient.recipient, + portion: (recipient.portion * total) / INDEX_DTF_FEE_RECIPIENT_TOTAL, + })); + const allButLast = scaled.slice(0, -1).reduce((sum, recipient) => sum + recipient.portion, 0n); + scaled[scaled.length - 1]!.portion = total - allButLast; + + return scaled; +} + +/** Mirrors FolioLib's checks so a bad table fails at build time instead of at execution. */ +export function assertIndexDtfFeeRecipientTables(tables: { + readonly recipients: readonly IndexDtfFeeRecipient[]; + readonly immutableRecipients: readonly IndexDtfFeeRecipient[]; + /** The Folio itself may not be a recipient; pass it when known. */ + readonly folio?: Address; +}) { + const folio = tables.folio === undefined ? undefined : BigInt(getAddress(tables.folio)); + const count = tables.recipients.length + tables.immutableRecipients.length; + + if (count === 0) { + return; + } + if (count > INDEX_DTF_MAX_FEE_RECIPIENTS) { + throw new SdkError({ + code: "INVALID_INPUT", + message: `fee recipients cannot exceed ${INDEX_DTF_MAX_FEE_RECIPIENTS}`, + meta: { count }, + }); + } + + for (const [name, table] of [ + ["recipients", tables.recipients], + ["immutableRecipients", tables.immutableRecipients], + ] as const) { + let previous = -1n; + + for (const entry of table) { + const address = BigInt(getAddress(entry.recipient)); + + if (address === 0n || address === folio || address <= previous) { + throw new SdkError({ + code: "INVALID_INPUT", + message: `${name} must be unique, non-zero, not the Folio itself, and sorted ascending by address`, + meta: { recipient: entry.recipient }, + }); + } + if (entry.portion <= 0n) { + throw new SdkError({ + code: "INVALID_INPUT", + message: `${name} portions must be positive`, + meta: { recipient: entry.recipient }, + }); + } + + previous = address; + } + } + + const total = [...tables.recipients, ...tables.immutableRecipients].reduce((sum, entry) => sum + entry.portion, 0n); + if (total !== INDEX_DTF_FEE_RECIPIENT_TOTAL) { + throw new SdkError({ + code: "INVALID_INPUT", + message: "fee recipient portions across both tables must total 100%", + meta: { total }, + }); + } +} diff --git a/packages/sdk/src/index-dtf/fork-real-v6.test.ts b/packages/sdk/src/index-dtf/fork-real-v6.test.ts new file mode 100644 index 0000000..8ac8bac --- /dev/null +++ b/packages/sdk/src/index-dtf/fork-real-v6.test.ts @@ -0,0 +1,1316 @@ +import { + BaseError, + createPublicClient, + createWalletClient, + decodeErrorResult, + encodeAbiParameters, + encodeFunctionData, + erc20Abi, + getAddress, + http, + keccak256, + maxUint256, + parseAbi, + parseEther, + parseEventLogs, + parseUnits, + toBytes, + toFunctionSelector, + toFunctionSignature, + toHex, + zeroAddress, + zeroHash, + type Abi, + type AbiFunction, + type AbiParameter, + type Address, + type Hex, + type PublicClient, + type TransactionReceipt, + type WalletClient, +} from "viem"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import type { SupportedChainId } from "@/config"; +import type { IndexDtfCall } from "@/types/governance"; + +import { SUPPORTED_CHAINS } from "@/config"; +import { createDtfSdk, type DtfSdk } from "@/create-dtf-sdk"; +import { readExplorerAbiFixture } from "@/index-dtf/abis/explorer-fixture"; +import { folioDeployerV6Abi } from "@/index-dtf/abis/folio-deployer-v6.generated"; +import { folioV6Abi } from "@/index-dtf/abis/folio-v6.generated"; +import { folioVersionRegistryAbi } from "@/index-dtf/abis/folio-version-registry.generated"; +import { + DEFAULT_INDEX_DTF_DEPLOY_FLAGS, + extractIndexDtfDeployedAddress, + getIndexDtfDeployerAddress, + INDEX_DTF_DEPLOYER_ADDRESS, + INDEX_DTF_V6_DEPLOYER_ADDRESS, +} from "@/index-dtf/deploy"; +import { assertDisposableForkRpcUrl } from "@/index-dtf/fork-smoke-fixture"; +import { INDEX_DTF_START_REBALANCE_SELECTOR } from "@/index-dtf/governance/propose/upgrade-v6"; +import { getIndexDtfVersionHash, INDEX_DTF_VERSION_REGISTRY_ADDRESS } from "@/index-dtf/version-registry"; + +type PriceRange = { readonly low: bigint; readonly high: bigint }; +type TokenBalances = { readonly native: bigint; readonly stable: bigint }; +type BidCheck = { + readonly label: string; + readonly timestamp: bigint; + readonly sellAmount: bigint; + readonly bidAmount: bigint; + readonly price: bigint; + readonly remainingSellAvailable: bigint; +}; + +/** + * Real-address Folio 6.0 evidence: the SDK's default v6 deployer and the deployed 6.0.0 bytecode on disposable, + * unindexed Anvil forks of mainnet, Base and BSC, through a complete rebalance (launcher and community auctions, + * closeAuction, endRebalance by nonce) with independently recomputed prices and sizes. Every write is impersonated + * and the whole chain run sits inside one `evm_snapshot`/`evm_revert`, so a fork can be reused. Start the forks first + * (see the SDK README). + */ +const runtimeEnv = + (globalThis as unknown as { process?: { env?: Record } }).process?.env ?? {}; +const forkDescribe = runtimeEnv.RUN_INDEX_DTF_FORK_REAL_V6 === "1" ? describe : describe.skip; +// Cold forks fetch every touched slot from the upstream RPC; BSC public endpoints are the slowest. +const forkTestTimeout = 300_000; +const D18 = 10n ** 18n; +const D27 = 10n ** 27n; +const FORK_TRANSACTION_GAS = 12_000_000n; + +// Loopback ports for the three disposable forks; 8545 is the shared, indexed sandbox and is refused. +const REAL_FORKS = [ + { + chainId: 1, + rpcUrl: runtimeEnv.INDEX_DTF_FORK_REAL_V6_RPC_URL_1 ?? "http://127.0.0.1:8546", + wrappedNative: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + stable: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + nativePrice: 2500, + }, + { + chainId: 8453, + rpcUrl: runtimeEnv.INDEX_DTF_FORK_REAL_V6_RPC_URL_8453 ?? "http://127.0.0.1:8547", + wrappedNative: "0x4200000000000000000000000000000000000006", + stable: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + nativePrice: 2500, + }, + { + chainId: 56, + rpcUrl: runtimeEnv.INDEX_DTF_FORK_REAL_V6_RPC_URL_56 ?? "http://127.0.0.1:8548", + wrappedNative: "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c", + stable: "0x55d398326f99059fF775485246999027B3197955", + nativePrice: 600, + }, +] as const satisfies readonly { + readonly chainId: SupportedChainId; + readonly rpcUrl: string; + readonly wrappedNative: Address; + readonly stable: Address; + // Fixture USD price. The actor is the only bidder, so prices only need to be self-consistent. + readonly nativePrice: number; +}[]; + +const ACTOR = getAddress("0x00000000000000000000000000000000000a11ce"); +// Holds no Folio role: proves openAuctionUnrestricted is permissionless after restrictedUntil. +const COMMUNITY = getAddress("0x00000000000000000000000000000000000c0de0"); +const EXTRA_GUARDIAN = getAddress("0x00000000000000000000000000000000000b0b00"); +const IMMUTABLE_RECIPIENT = getAddress("0x000000000000000000000000000000000000bEEF"); +const MAX_AUCTION_LENGTH = 1800n; +const AUCTION_LAUNCHER_WINDOW = 3600n; +const REBALANCE_TTL = 7200n; +const TARGET_NATIVE_SHARE_PERCENT = 20; +// Folio constants (contracts/utils/Constants.sol) the independent arithmetic needs. +const AUCTION_WARMUP = 30n; +const MAX_TOKEN_BUY_AMOUNT = 10n ** 36n; +// Mid-auction prices come from PRBMath ln/exp; the closed-form curve agrees to well within 1e-9. +const PRICE_TOLERANCE = 1e-9; +const PRICE_TOLERANCE_PARTS = 1_000_000_000n; +const FOLIO_6_0_0 = readExplorerAbiFixture("folio-6.0.0.base.json"); +const FOLIO_DEPLOYER_6_0_0 = readExplorerAbiFixture("folio-deployer-6.0.0.base.json"); +const SELF_FEE = parseEther("0.1"); +const ERC1967_IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; +const DEFAULT_ADMIN_ROLE = zeroHash; +const REBALANCE_MANAGER = keccak256(toBytes("REBALANCE_MANAGER")); +const AUCTION_LAUNCHER = keccak256(toBytes("AUCTION_LAUNCHER")); +const PROPOSER_ROLE = keccak256(toBytes("PROPOSER_ROLE")); +const EXECUTOR_ROLE = keccak256(toBytes("EXECUTOR_ROLE")); +const CANCELLER_ROLE = keccak256(toBytes("CANCELLER_ROLE")); +const OPTIMISTIC_PARAMS = { vetoDelay: 60n, vetoPeriod: 300n, vetoThreshold: parseEther("0.05") } as const; + +// The SDK has no builders for these: wrapping native, the vote-lock vault from the optimistic governor +// deployer (the v6 governed deploy needs an existing optimistic vault), and registry administration. +const wrappedNativeAbi = parseAbi(["function deposit() payable"]); +const accessControlAbi = parseAbi([ + "function hasRole(bytes32 role, address account) view returns (bool)", + "function getRoleMemberCount(bytes32 role) view returns (uint256)", + "function getRoleMember(bytes32 role, uint256 index) view returns (address)", + "function isOwner(address account) view returns (bool)", + "function owner() view returns (address)", +]); +const optimisticGovernorDeployerAbi = parseAbi([ + "struct OptimisticGovernanceParams { uint48 vetoDelay; uint32 vetoPeriod; uint256 vetoThreshold; }", + "struct StandardGovernanceParams { uint48 votingDelay; uint32 votingPeriod; uint48 voteExtension; uint256 proposalThreshold; uint256 quorumNumerator; }", + "struct SelectorData { address target; bytes4[] selectors; }", + "struct BaseDeploymentParams { OptimisticGovernanceParams optimisticParams; StandardGovernanceParams standardParams; SelectorData[] selectorData; address[] optimisticProposers; address[] additionalGuardians; uint256 timelockDelay; uint256 proposalThrottleCapacity; }", + "struct NewStakingVaultParams { address underlying; address[] rewardTokens; uint256 rewardHalfLife; uint256 unstakingDelay; }", + "function deployWithNewStakingVault(BaseDeploymentParams baseParams, NewStakingVaultParams newStakingVaultParams, bytes32 deploymentNonce) returns (address stakingVault, address governor, address timelock, address selectorRegistry)", + "function version() view returns (string)", + "function guardian() view returns (address)", + "event ReserveOptimisticGovernorSystemDeployed(address indexed stakingVault, address indexed governor, address indexed timelock, address optimisticSelectorRegistry)", +]); + +forkDescribe.each(REAL_FORKS)("Folio 6.0 real deployer fork on chain $chainId", (fork) => { + const chainId = fork.chainId; + const chain = SUPPORTED_CHAINS[chainId]; + const deployer = INDEX_DTF_V6_DEPLOYER_ADDRESS[chainId]; + const wrappedNative = getAddress(fork.wrappedNative); + const stable = getAddress(fork.stable); + let publicClient: PublicClient; + let wallet: WalletClient; + let sdk: DtfSdk; + let snapshot: Hex | undefined; + let stableDecimals: number; + let folioImplementation: Address; + let optimisticGovernorDeployer: Address; + let forkBlock: bigint; + let folio: Address; + let rebalanceNonce: bigint; + let initialBalances: TokenBalances; + const tradedByAuction = new Map(); + const impersonated = new Set
(); + + beforeAll(async () => { + assertDisposableForkRpcUrl(fork.rpcUrl); + publicClient = createPublicClient({ chain, transport: http(fork.rpcUrl, { timeout: 120_000 }) }); + wallet = createWalletClient({ chain, transport: http(fork.rpcUrl, { timeout: 120_000 }) }); + sdk = createDtfSdk({ chains: { [chainId]: { publicClient } } }); + + const clientVersion = await publicClient.request({ method: "web3_clientVersion" }); + if (!clientVersion.toLowerCase().startsWith("anvil")) { + throw new Error(`${fork.rpcUrl} is not an Anvil fork (${clientVersion})`); + } + expect(await publicClient.getChainId()).toBe(chainId); + forkBlock = await publicClient.getBlockNumber(); + snapshot = (await publicClient.request({ method: "evm_snapshot" as never, params: [] as never })) as Hex; + + await impersonate(ACTOR); + await send(ACTOR, { + chainId, + to: wrappedNative, + data: encodeFunctionData({ abi: wrappedNativeAbi, functionName: "deposit" }), + value: parseEther("10"), + }); + stableDecimals = await publicClient.readContract({ address: stable, abi: erc20Abi, functionName: "decimals" }); + await dealErc20(stable, ACTOR, parseUnits("1000000", stableDecimals)); + await impersonate(COMMUNITY); + }, forkTestTimeout); + + afterAll(async () => { + if (!publicClient) return; + for (const account of impersonated) { + await publicClient.request({ method: "anvil_stopImpersonatingAccount" as never, params: [account] as never }); + } + if (snapshot) await publicClient.request({ method: "evm_revert" as never, params: [snapshot] as never }); + }, forkTestTimeout); + + it( + "defaults the v6 target to the chain's real 6.0.0 deployer and reads its production wiring", + async ({ annotate }) => { + const v5Deployer = INDEX_DTF_DEPLOYER_ADDRESS[chainId]; + expect(getIndexDtfDeployerAddress({ chainId, version: "6.0.0" })).toBe(deployer); + + const [version, implementation, versionRegistry, daoFeeRegistry, trustedFillerRegistry, governorDeployer] = + await publicClient.multicall({ + allowFailure: false, + contracts: [ + { address: deployer, abi: folioDeployerV6Abi, functionName: "version" }, + { address: deployer, abi: folioDeployerV6Abi, functionName: "folioImplementation" }, + { address: deployer, abi: folioDeployerV6Abi, functionName: "versionRegistry" }, + { address: deployer, abi: folioDeployerV6Abi, functionName: "daoFeeRegistry" }, + { address: deployer, abi: folioDeployerV6Abi, functionName: "trustedFillerRegistry" }, + { address: deployer, abi: folioDeployerV6Abi, functionName: "optimisticGovernorDeployer" }, + ], + }); + // The v5 deployer shares the registry wiring; its ABI has the same getters. + const [v5DaoFeeRegistry, v5TrustedFillerRegistry] = await publicClient.multicall({ + allowFailure: false, + contracts: [ + { address: v5Deployer, abi: folioDeployerV6Abi, functionName: "daoFeeRegistry" }, + { address: v5Deployer, abi: folioDeployerV6Abi, functionName: "trustedFillerRegistry" }, + ], + }); + folioImplementation = getAddress(implementation); + optimisticGovernorDeployer = getAddress(governorDeployer); + // The implementation is the explorer-verified 6.0.0 the SDK ABIs are checked against, byte for byte. + const runtimeCode = await publicClient.getCode({ address: folioImplementation }); + expect(runtimeCode && keccak256(runtimeCode)).toBe(FOLIO_6_0_0.runtimeCodeKeccak256); + if (chainId === FOLIO_6_0_0.chainId) { + expect(folioImplementation).toBe(FOLIO_6_0_0.address); + // The deployer's runtime code carries chain-specific immutables, so its verified hash only pins Base. + const deployerCode = await publicClient.getCode({ address: deployer }); + expect(deployer).toBe(FOLIO_DEPLOYER_6_0_0.address); + expect(deployerCode && keccak256(deployerCode)).toBe(FOLIO_DEPLOYER_6_0_0.runtimeCodeKeccak256); + } + + expect(version).toBe("6.0.0"); + expect(getAddress(versionRegistry)).toBe(INDEX_DTF_VERSION_REGISTRY_ADDRESS[chainId]); + expect(getAddress(daoFeeRegistry)).toBe(getAddress(v5DaoFeeRegistry)); + expect(getAddress(trustedFillerRegistry)).toBe(getAddress(v5TrustedFillerRegistry)); + expect( + await publicClient.readContract({ + address: optimisticGovernorDeployer, + abi: optimisticGovernorDeployerAbi, + functionName: "version", + }), + ).toBe("1.1.0"); + await annotate( + `chain ${chainId} fork head ${forkBlock}: deployer ${deployer}, implementation ${folioImplementation}, daoFeeRegistry ${daoFeeRegistry}, trustedFillerRegistry ${trustedFillerRegistry}, OGD 1.1.0 ${optimisticGovernorDeployer}`, + ); + }, + forkTestTimeout, + ); + + it( + "deploys an ungoverned Folio through the default deployer and reads the v6 state back through the SDK", + async ({ annotate }) => { + const basicDetails = getBasicDetails("Real v6 fork", "RV6F"); + const plan = sdk.index.prepareDeployPlan({ + chainId, + version: "6.0.0", + basicDetails, + additionalDetails: { + maxAuctionLength: MAX_AUCTION_LENGTH, + feeRecipients: [{ recipient: ACTOR, portion: parseEther("0.6") }], + immutableFeeRecipients: [{ recipient: IMMUTABLE_RECIPIENT, portion: parseEther("0.4") }], + tvlFee: 0n, + mintFee: 0n, + selfFee: SELF_FEE, + mandate: "Real v6 deployer fork evidence", + }, + flags: DEFAULT_INDEX_DTF_DEPLOY_FLAGS, + owner: ACTOR, + basketManagers: [ACTOR], + auctionLaunchers: [ACTOR], + deploymentNonce: keccak256(toBytes(`fork-real-v6-ungoverned-${chainId}`)), + approvals: basicDetails.assets.map((token, index) => ({ token, amount: basicDetails.amounts[index]! })), + }); + if (plan.type !== "approval-required") throw new Error("expected an approval plan"); + expect(plan.call.to).toBe(deployer); + expect(plan.approvals.map((approval) => approval.contract.args[0])).toEqual([deployer, deployer]); + + for (const approval of plan.approvals) await send(ACTOR, approval); + const receipt = await send(ACTOR, plan.call); + folio = extractIndexDtfDeployedAddress(receipt.logs, { chainId, version: "6.0.0" }); + const [deployed] = parseEventLogs({ + abi: folioDeployerV6Abi, + eventName: "FolioDeployed", + logs: receipt.logs.filter((log) => getAddress(log.address) === deployer), + }); + const params = { address: folio, chainId } as const; + + expect(deployed?.args).toMatchObject({ folioOwner: ACTOR, folio }); + expect(await sdk.index.getVersion(params)).toBe("6.0.0"); + expect(await getImplementation(folio)).toBe(folioImplementation); + expect(await sdk.index.getMaxAuctionLength(params)).toBe(MAX_AUCTION_LENGTH); + expect(await sdk.index.getSelfFee(params)).toEqual({ raw: SELF_FEE, formatted: "0.1" }); + expect(await sdk.index.getImmutableFeeRecipients(params)).toEqual([ + { recipient: IMMUTABLE_RECIPIENT, portion: parseEther("0.4") }, + ]); + // The mutable table has no SDK RPC read (it comes from the subgraph), so it is read from the contract. + expect(await readMutableFeeRecipients(folio)).toEqual([{ recipient: ACTOR, portion: parseEther("0.6") }]); + expect(await sdk.index.getTradeAllowlist(params)).toEqual({ enabled: false, tokens: [] }); + expect(await sdk.index.getIsTokenAllowlisted({ ...params, token: wrappedNative })).toBe(false); + expect(await sdk.index.getRebalanceNonce(params)).toBe(0n); + expect(await sdk.index.getRebalanceControl(params)).toEqual({ weightControl: true, priceControl: 1 }); + expect(await sdk.index.getBidsEnabled(params)).toBe(true); + expect((await sdk.index.getTotalAssets(params)).balanceByToken).toEqual({ + [wrappedNative]: basicDetails.amounts[0], + [stable]: basicDetails.amounts[1], + }); + expect(await hasRoles(folio, DEFAULT_ADMIN_ROLE, [ACTOR, deployer])).toEqual([true, false]); + expect(await hasRoles(folio, REBALANCE_MANAGER, [ACTOR])).toEqual([true]); + expect(await hasRoles(folio, AUCTION_LAUNCHER, [ACTOR])).toEqual([true]); + await annotate(`chain ${chainId} ungoverned folio ${folio} (proxy admin ${deployed?.args.folioAdmin})`); + }, + forkTestTimeout, + ); + + it( + "dispatches every function in the SDK v6 Folio ABI on the deployed implementation", + async ({ annotate }) => { + // Probed through the deployed proxy, whose ERC-1967 slot is the chain's folioImplementation(): an initialized + // Folio answers the fee-registry and token reads an uninitialized implementation reverts on without data. + expect(await getImplementation(folio)).toBe(folioImplementation); + // Control: an unknown selector falls through Folio's dispatcher and reverts with empty data, and so does the + // pre-audit `endRebalance()` the SDK used to encode. + expect(await probeCall(folio, "0xdeadbeef")).toEqual({ reverted: true, data: "0x" }); + expect(await probeCall(folio, toFunctionSelector("endRebalance()"))).toEqual({ reverted: true, data: "0x" }); + + const functions = (folioV6Abi as Abi).filter((item): item is AbiFunction => item.type === "function"); + const results = await Promise.all( + functions.map(async (item) => { + const data = encodeFunctionData({ + abi: [item], + functionName: item.name, + args: item.inputs.map((input) => probeArgument(input, wrappedNative)), + }); + return { signature: toFunctionSignature(item), result: await probeCall(folio, data) }; + }), + ); + const undispatched = results.filter(({ result }) => result.reverted && result.data === "0x"); + // Every revert carries an error the Folio ABI (or Error/Panic) decodes: the call ran past the dispatcher. + const errors = results.flatMap(({ signature, result }) => + result.reverted && result.data !== "0x" + ? [ + `${signature.slice(0, signature.indexOf("("))}: ${decodeErrorResult({ abi: folioV6Abi, data: result.data }).errorName}`, + ] + : [], + ); + + expect(undispatched.map(({ signature }) => signature)).toEqual([]); + await annotate( + `chain ${chainId}: all ${functions.length} SDK v6 Folio functions dispatch on ${folioImplementation}; ${functions.length - errors.length} returned, ${errors.length} reverted with decoded errors (${errors.join(", ")})`, + ); + }, + forkTestTimeout, + ); + + it( + "starts a rebalance with the SDK basket builder at the expected nonce", + async ({ annotate }) => { + const params = { address: folio, chainId } as const; + const latest = await publicClient.getBlock(); + initialBalances = await readFolioBalances(); + const basketProposal = await sdk.index.buildBasketProposal({ + ...params, + version: "6.0.0", + // Ungoverned: the basket manager sends the built startRebalance call itself. + governance: ACTOR, + weightControl: true, + prices: { [wrappedNative]: fork.nativePrice, [stable]: 1 }, + priceErrors: { [wrappedNative]: 0.1, [stable]: 0.1 }, + basket: { + type: "shares", + tokens: [ + { address: wrappedNative, share: TARGET_NATIVE_SHARE_PERCENT }, + { address: stable, share: 100 - TARGET_NATIVE_SHARE_PERCENT }, + ], + }, + auctionLauncherWindow: Number(AUCTION_LAUNCHER_WINDOW), + ttl: Number(REBALANCE_TTL), + deadline: latest.timestamp + 3600n, + }); + expect(basketProposal.targets).toEqual([folio]); + expect(basketProposal.context.rebalanceNonce).toBe(1n); + const receipt = await send( + ACTOR, + { chainId, to: folio, data: basketProposal.calldatas[0]!, value: 0n }, + { syncedFolioCall: true }, + ); + const [started] = parseEventLogs({ abi: folioV6Abi, eventName: "RebalanceStarted", logs: receipt.logs }); + rebalanceNonce = await sdk.index.getRebalanceNonce(params); + + expect(rebalanceNonce).toBe(1n); + expect(started?.args.nonce).toBe(1n); + const { timestamps } = await readRebalance(); + expect(timestamps.restrictedUntil - timestamps.startedAt).toBe(AUCTION_LAUNCHER_WINDOW); + expect(timestamps.availableUntil - timestamps.startedAt).toBe(REBALANCE_TTL); + // Direction from the on-chain rebalance: at spot, the targets hold less native and more stable than the + // Folio does. {tok} = D18{BU/share} * D27{tok/BU} * {share} / (1e18 * 1e27). + const current = await sdk.index.getCurrentRebalance(params); + const spotTarget = (token: Address) => { + const details = current.rebalance.tokens.find((entry) => getAddress(entry.token) === token); + if (!details) throw new Error(`${token} is not in the rebalance`); + return (current.rebalance.limits.spot * details.weight.spot * current.totalSupply) / (D18 * D27); + }; + // Independently: 20% of the basket's value at the fixture prices in native, 80% in the stable. + const stableUnit = 10n ** BigInt(stableDecimals); + const nativePrice = BigInt(fork.nativePrice); + const valueInStable = (initialBalances.native * nativePrice * stableUnit) / D18 + initialBalances.stable; + const targetShare = BigInt(TARGET_NATIVE_SHARE_PERCENT); + expect(spotTarget(wrappedNative)).toBe((valueInStable * targetShare * D18) / (100n * nativePrice * stableUnit)); + expect(spotTarget(stable)).toBe((valueInStable * (100n - targetShare)) / 100n); + expect(spotTarget(wrappedNative)).toBeLessThan(initialBalances.native); + expect(spotTarget(stable)).toBeGreaterThan(initialBalances.stable); + await annotate( + `chain ${chainId} rebalance 1 from ${initialBalances.native} native / ${initialBalances.stable} stable; spot targets ${spotTarget(wrappedNative)} / ${spotTarget(stable)}`, + ); + }, + forkTestTimeout, + ); + + it( + "launcher auction: bids at start, mid and end settle at independently computed prices and limits", + async ({ annotate }) => { + const params = { address: folio, chainId } as const; + const current = await sdk.index.getCurrentRebalance(params); + const tokens = await Promise.all( + current.rebalance.tokens.map(async ({ token }) => { + const address = getAddress(token); + const [symbol, name, decimals] = await publicClient.multicall({ + allowFailure: false, + contracts: [ + { address, abi: erc20Abi, functionName: "symbol" }, + { address, abi: erc20Abi, functionName: "name" }, + { address, abi: erc20Abi, functionName: "decimals" }, + ], + }); + return { address, symbol, name, decimals }; + }), + ); + const assets = Object.fromEntries( + current.totalAssets.tokens.map((token, index) => [token.toLowerCase(), current.totalAssets.balances[index]!]), + ); + const usdPrices = { [wrappedNative.toLowerCase()]: fork.nativePrice, [stable.toLowerCase()]: 1 }; + const auctionLength = await sdk.index.getMaxAuctionLength(params); + const built = sdk.index.prepareOpenAuctionArgs({ + version: "6.0.0", + auctionLength, + rebalance: current.rebalance, + tokens, + supply: current.totalSupply, + initialSupply: current.totalSupply, + currentAssets: assets, + initialAssets: assets, + initialPrices: usdPrices, + initialWeights: Object.fromEntries( + current.rebalance.tokens.map(({ token, weight }) => [token.toLowerCase(), weight]), + ), + prices: Object.fromEntries( + Object.entries(usdPrices).map(([token, price]) => [token, { currentPrice: price, snapshotPrice: price }]), + ), + tokenPriceVolatility: { [wrappedNative.toLowerCase()]: 0.1, [stable.toLowerCase()]: 0.1 }, + rebalancePercent: 95, + isTrackingDtf: false, + }); + expect(built.args.rebalanceNonce).toBe(rebalanceNonce); + // The actor is the only bidder; the balance deltas below check each payment exactly. + await send(ACTOR, { + chainId, + to: stable, + data: encodeFunctionData({ abi: erc20Abi, functionName: "approve", args: [folio, maxUint256] }), + value: 0n, + }); + await send(ACTOR, sdk.index.prepareOpenAuction({ ...params, version: "6.0.0", args: built.args }), { + syncedFolioCall: true, + }); + + const auction = await sdk.index.getLatestAuction(params); + if (!auction) throw new Error("openAuction left no auction"); + expect(auction).toMatchObject({ auctionId: 0n, rebalanceNonce, currentRebalanceNonce: rebalanceNonce }); + expect(auction.endTime - auction.startTime).toBe(auctionLength); + // The prices the SDK built are the ones the auction stored. + const builtPrice = (token: Address) => + built.args.newPrices[built.args.tokens.findIndex((entry) => getAddress(entry) === token)]; + expect(await readAuctionPrice(0n, wrappedNative)).toEqual(builtPrice(wrappedNative)); + expect(await readAuctionPrice(0n, stable)).toEqual(builtPrice(stable)); + expectPricesBracketFairPrice(builtPrice(wrappedNative)!, builtPrice(stable)!); + + const midpoint = auction.startTime + auctionLength / 2n; + const bids = [ + await bidAndCheck({ auctionId: 0n, timestamp: auction.startTime, take: [1n, 4n], label: "start" }), + await bidAndCheck({ auctionId: 0n, timestamp: midpoint, take: [1n, 3n], label: "mid" }), + await bidAndCheck({ auctionId: 0n, timestamp: auction.endTime, take: [1n, 2n], label: "end" }), + ]; + // The Dutch auction descends: the bidder pays less buy token per sell token as time passes. + expect(bids[0]!.price).toBeGreaterThan(bids[1]!.price); + expect(bids[1]!.price).toBeGreaterThan(bids[2]!.price); + + // closeAuction after endTime is a no-op by design (no revert, so it cannot be griefed): no event, same window. + await setNextBlockTimestamp(auction.endTime + 1n); + const closeReceipt = await send( + ACTOR, + sdk.index.prepareCloseAuction({ ...params, version: "6.0.0", auctionId: 0n }), + ); + expect(parseEventLogs({ abi: folioV6Abi, eventName: "AuctionClosed", logs: closeReceipt.logs })).toEqual([]); + expect(await readAuctionWindow(0n)).toEqual({ startTime: auction.startTime, endTime: auction.endTime }); + await annotate( + `chain ${chainId} launcher auction 0 [${auction.startTime}, ${auction.endTime}]: ${bids.map(describeBid).join("; ")}`, + ); + }, + forkTestTimeout, + ); + + it( + "community auction: openAuctionUnrestricted only after restrictedUntil, closed mid-auction by the launcher", + async ({ annotate }) => { + const params = { address: folio, chainId } as const; + const openUnrestricted = sdk.index.prepareOpenAuctionUnrestricted({ + ...params, + version: "6.0.0", + rebalanceNonce, + }); + const { timestamps, limits, tokens } = await readRebalance(); + expect((await publicClient.getBlock()).timestamp).toBeLessThan(timestamps.restrictedUntil); + await expectFolioRevert(COMMUNITY, openUnrestricted, "Folio__AuctionCannotBeOpenedWithoutRestriction"); + + // Anyone may open it from restrictedUntil on: the community member holds no Folio role. + expect(await hasRoles(folio, AUCTION_LAUNCHER, [COMMUNITY])).toEqual([false]); + await setNextBlockTimestamp(timestamps.restrictedUntil); + const openReceipt = await send(COMMUNITY, openUnrestricted, { syncedFolioCall: true }); + const [opened] = parseEventLogs({ abi: folioV6Abi, eventName: "AuctionOpened", logs: openReceipt.logs }); + expect(opened?.args).toMatchObject({ rebalanceNonce, auctionId: 1n }); + + // Unrestricted auctions run on the rebalance's spot limits and weights and its initial prices. + const window = await readAuctionWindow(1n); + const maxAuctionLength = await sdk.index.getMaxAuctionLength(params); + expect(window).toEqual({ + startTime: timestamps.restrictedUntil + AUCTION_WARMUP, + endTime: timestamps.restrictedUntil + AUCTION_WARMUP + maxAuctionLength, + }); + const afterOpen = await readRebalance(); + expect(afterOpen.limits).toEqual({ low: limits.spot, spot: limits.spot, high: limits.spot }); + for (const token of [wrappedNative, stable]) { + const before = tokens.find((entry) => getAddress(entry.token) === token)!; + const after = afterOpen.tokens.find((entry) => getAddress(entry.token) === token)!; + expect(after.weight).toEqual({ low: before.weight.spot, spot: before.weight.spot, high: before.weight.spot }); + expect(await readAuctionPrice(1n, token)).toEqual(before.price); + } + expectPricesBracketFairPrice(await readAuctionPrice(1n, wrappedNative), await readAuctionPrice(1n, stable)); + + // One bid for everything available at the exact start price takes the basket to the spot target on the + // binding side; the auction stays open (a zero-size quote still prices it) until the launcher closes it. + const bid = await bidAndCheck({ + auctionId: 1n, + timestamp: window.startTime, + take: [1n, 1n], + label: "start, all available", + }); + expect(bid.remainingSellAvailable).toBe(0n); + const midpoint = window.startTime + maxAuctionLength / 2n; + await setNextBlockTimestamp(midpoint - 1n); + await publicClient.request({ method: "evm_mine" as never, params: [] as never }); + expect((await readBid(1n, 0n, await publicClient.getBlockNumber({ cacheTime: 0 })))[0]).toBe(0n); + + await setNextBlockTimestamp(midpoint); + const closeReceipt = await send( + ACTOR, + sdk.index.prepareCloseAuction({ ...params, version: "6.0.0", auctionId: 1n }), + ); + const [closed] = parseEventLogs({ abi: folioV6Abi, eventName: "AuctionClosed", logs: closeReceipt.logs }); + const closedAt = (await publicClient.getBlock({ blockNumber: closeReceipt.blockNumber })).timestamp; + expect(closed?.args.auctionId).toBe(1n); + expect(closedAt).toBe(midpoint); + expect(await readAuctionWindow(1n)).toEqual({ startTime: window.startTime, endTime: closedAt - 1n }); + // A zero-size quote prices any open auction; after the close the pair is no longer on sale at all. + await expectFolioRevert( + COMMUNITY, + { + to: folio, + data: encodeFunctionData({ + abi: folioV6Abi, + functionName: "getBid", + args: [1n, wrappedNative, stable, 0n], + }), + }, + "Folio__AuctionNotOngoing", + ); + await annotate( + `chain ${chainId} community auction 1 [${window.startTime}, ${window.endTime}] opened by ${COMMUNITY} at restrictedUntil ${timestamps.restrictedUntil}: ${describeBid(bid)}; closed at ${closedAt}`, + ); + }, + forkTestTimeout, + ); + + it( + "ends the rebalance by nonce, rejects a stale nonce, and leaves the basket moved toward the target within limits", + async ({ annotate }) => { + const params = { address: folio, chainId } as const; + await expectFolioRevert( + ACTOR, + sdk.index.prepareEndRebalance({ ...params, version: "6.0.0", rebalanceNonce: rebalanceNonce + 1n }), + "Folio__InvalidRebalanceNonce", + ); + await expectFolioRevert( + ACTOR, + sdk.index.prepareEndRebalance({ ...params, version: "6.0.0", rebalanceNonce: rebalanceNonce - 1n }), + "Folio__InvalidRebalanceNonce", + ); + // Only the admin, rebalance manager or auction launcher may end it. + await expectFolioRevert( + COMMUNITY, + sdk.index.prepareEndRebalance({ ...params, version: "6.0.0", rebalanceNonce }), + "Folio__Unauthorized", + ); + + const receipt = await send(ACTOR, sdk.index.prepareEndRebalance({ ...params, version: "6.0.0", rebalanceNonce })); + const [ended] = parseEventLogs({ abi: folioV6Abi, eventName: "RebalanceEnded", logs: receipt.logs }); + const endedAt = (await publicClient.getBlock({ blockNumber: receipt.blockNumber })).timestamp; + const final = await readRebalance(); + expect(ended?.args.nonce).toBe(rebalanceNonce); + expect(final.nonce).toBe(rebalanceNonce); + expect(final.timestamps.availableUntil).toBe(endedAt); + await expectFolioRevert( + COMMUNITY, + sdk.index.prepareOpenAuctionUnrestricted({ ...params, version: "6.0.0", rebalanceNonce }), + "Folio__NotRebalancing", + ); + + // Within limits: never sold below the sell limit nor bought past the buy limit of the spot target. + const supply = await readTotalSupply(); + const balances = await readFolioBalances(); + const nativeDetails = final.tokens.find((entry) => getAddress(entry.token) === wrappedNative)!; + const stableDetails = final.tokens.find((entry) => getAddress(entry.token) === stable)!; + const nativeFloor = ceilDiv(ceilDiv(final.limits.high * nativeDetails.weight.high, D18) * supply, D27); + const stableCeiling = (((final.limits.low * stableDetails.weight.low) / D18) * supply) / D27; + expect(balances.native).toBeGreaterThanOrEqual(nativeFloor); + expect(balances.stable).toBeLessThanOrEqual(stableCeiling); + // And moved from 50% native by value to within two points of the 20% target (the spot amounts are pinned + // exactly at startRebalance). Bidders pay above or below the fixture price, so the first side to bind leaves the + // other a little off its own spot amount. + const initialShare = nativeValueShare(initialBalances); + const finalShare = nativeValueShare(balances); + expect(initialShare).toBeCloseTo(0.5, 6); + expect(Math.abs(finalShare - TARGET_NATIVE_SHARE_PERCENT / 100)).toBeLessThan(0.02); + await annotate( + `chain ${chainId} rebalance ${rebalanceNonce} ended at ${endedAt}: native ${initialBalances.native} -> ${balances.native} (floor ${nativeFloor}), stable ${initialBalances.stable} -> ${balances.stable} (ceiling ${stableCeiling}); native value share ${initialShare.toFixed(4)} -> ${finalShare.toFixed(4)} (target ${TARGET_NATIVE_SHARE_PERCENT / 100})`, + ); + }, + forkTestTimeout, + ); + + it( + "deploys a governed Folio through the default deployer, which wires optimistic governance through OGD 1.1.0", + async ({ annotate }) => { + const vault = await deployOptimisticStakingVault(); + const basicDetails = getBasicDetails("Real v6 governed fork", "RV6G"); + const plan = sdk.index.prepareDeployGovernedPlan({ + chainId, + version: "6.0.0", + stToken: vault, + basicDetails, + additionalDetails: { + maxAuctionLength: MAX_AUCTION_LENGTH, + feeRecipients: [{ recipient: vault, portion: parseEther("0.5") }], + immutableFeeRecipients: [{ recipient: IMMUTABLE_RECIPIENT, portion: parseEther("0.5") }], + tvlFee: 0n, + mintFee: 0n, + selfFee: 0n, + mandate: "Real v6 governed deployer fork evidence", + }, + flags: DEFAULT_INDEX_DTF_DEPLOY_FLAGS, + governance: { + optimistic: OPTIMISTIC_PARAMS, + standard: { + votingDelay: 60, + votingPeriod: 300, + voteExtension: 0, + proposalThreshold: parseEther("0.01"), + quorumNumerator: parseEther("0.01"), + }, + optimisticSelectors: [INDEX_DTF_START_REBALANCE_SELECTOR["6.0.0"]], + optimisticProposers: [ACTOR], + additionalGuardians: [EXTRA_GUARDIAN], + timelockDelay: 2n, + proposalThrottleCapacity: 10n, + }, + roles: { auctionLaunchers: [ACTOR] }, + deploymentNonce: keccak256(toBytes(`fork-real-v6-governed-${chainId}`)), + approvals: basicDetails.assets.map((token, index) => ({ token, amount: basicDetails.amounts[index]! })), + }); + if (plan.type !== "approval-required") throw new Error("expected an approval plan"); + expect(plan.call.to).toBe(deployer); + for (const approval of plan.approvals) await send(ACTOR, approval); + const receipt = await send(ACTOR, plan.call); + + const governedFolio = extractIndexDtfDeployedAddress(receipt.logs, { chainId, version: "6.0.0" }); + const [governed] = parseEventLogs({ + abi: folioDeployerV6Abi, + eventName: "GovernedFolioDeployed", + logs: receipt.logs.filter((log) => getAddress(log.address) === deployer), + }); + const [folioDeployed] = parseEventLogs({ + abi: folioDeployerV6Abi, + eventName: "FolioDeployed", + logs: receipt.logs.filter((log) => getAddress(log.address) === deployer), + }); + const systemLogs = parseEventLogs({ + abi: optimisticGovernorDeployerAbi, + eventName: "ReserveOptimisticGovernorSystemDeployed", + logs: receipt.logs, + }); + if (!governed || !folioDeployed) throw new Error("governed deploy events are missing"); + const { ownerGovernor: governor, ownerTimelock: timelock } = governed.args; + + expect(governed.args).toMatchObject({ stToken: vault, folio: governedFolio, tradingGovernor: governor }); + expect(governed.args.tradingTimelock).toBe(timelock); + expect(systemLogs).toHaveLength(1); + expect(getAddress(systemLogs[0]!.address)).toBe(optimisticGovernorDeployer); + expect(systemLogs[0]!.args).toMatchObject({ stakingVault: zeroAddress, governor, timelock }); + const selectorRegistry = systemLogs[0]!.args.optimisticSelectorRegistry; + + const governance = await sdk.index.getOptimisticGovernance({ chainId, governance: governor }); + const guardian = await publicClient.readContract({ + address: optimisticGovernorDeployer, + abi: optimisticGovernorDeployerAbi, + functionName: "guardian", + }); + expect(governance).toMatchObject({ + token: vault, + timelock, + selectorRegistry, + proposalThrottleCapacity: 10n, + lateQuorumVoteExtension: 0n, + optimisticParams: OPTIMISTIC_PARAMS, + optimisticProposers: [ACTOR], + }); + // `guardians` lists every timelock CANCELLER_ROLE member, like the subgraph: that includes the governor. + expect([...governance.guardians].sort()).toEqual([getAddress(guardian), EXTRA_GUARDIAN, governor].sort()); + expect(await sdk.index.getOptimisticSelectors({ chainId, registry: selectorRegistry })).toEqual([ + { target: governedFolio, selector: INDEX_DTF_START_REBALANCE_SELECTOR["6.0.0"] }, + ]); + expect(await sdk.index.getVersion({ address: governedFolio, chainId })).toBe("6.0.0"); + expect(await getImplementation(governedFolio)).toBe(folioImplementation); + expect(await readRoleMembers(governedFolio, DEFAULT_ADMIN_ROLE)).toEqual([timelock]); + expect(await readRoleMembers(governedFolio, REBALANCE_MANAGER)).toEqual([timelock]); + expect(await readRoleMembers(governedFolio, AUCTION_LAUNCHER)).toEqual([ACTOR]); + expect( + await publicClient.readContract({ + address: folioDeployed.args.folioAdmin, + abi: accessControlAbi, + functionName: "owner", + }), + ).toBe(timelock); + expect(await hasRoles(timelock, PROPOSER_ROLE, [governor])).toEqual([true]); + expect(await hasRoles(timelock, EXECUTOR_ROLE, [governor])).toEqual([true]); + expect(await hasRoles(timelock, CANCELLER_ROLE, [governor, getAddress(guardian), EXTRA_GUARDIAN])).toEqual([ + true, + true, + true, + ]); + expect(await hasRoles(timelock, DEFAULT_ADMIN_ROLE, [optimisticGovernorDeployer, deployer])).toEqual([ + false, + false, + ]); + await annotate( + `chain ${chainId} governed folio ${governedFolio}: governor ${governor}, timelock ${timelock}, selector registry ${selectorRegistry}, vault ${vault}`, + ); + }, + forkTestTimeout, + ); + + it( + "registers the real deployer on the fork and reads 6.0.0 back through the SDK version registry reads", + async ({ annotate }) => { + const registry = INDEX_DTF_VERSION_REGISTRY_ADDRESS[chainId]; + // Once production registers 6.0.0 this only checks that the registered deployer is the pinned one. + const registered = await sdk.index.getVersionDeployment({ chainId, version: "6.0.0" }); + if (!registered) { + expect(await sdk.index.getLatestVersion({ chainId })).toMatchObject({ + version: "5.0.0", + deployer: INDEX_DTF_DEPLOYER_ADDRESS[chainId], + }); + const roleRegistry = await publicClient.readContract({ + address: registry, + abi: folioVersionRegistryAbi, + functionName: "roleRegistry", + }); + const owner = await findRoleRegistryOwner(roleRegistry); + await impersonate(owner); + await send(owner, { + chainId, + to: registry, + data: encodeFunctionData({ abi: folioVersionRegistryAbi, functionName: "registerVersion", args: [deployer] }), + value: 0n, + }); + await annotate( + `chain ${chainId} registry ${registry}: registered as role registry ${roleRegistry} owner ${owner}`, + ); + } + + expect(await sdk.index.getLatestVersion({ chainId })).toEqual({ + versionHash: getIndexDtfVersionHash("6.0.0"), + version: "6.0.0", + deployer, + deprecated: false, + }); + expect(await sdk.index.getVersionDeployment({ chainId, version: "6.0.0" })).toEqual({ + version: "6.0.0", + versionHash: getIndexDtfVersionHash("6.0.0"), + deployer, + implementation: folioImplementation, + deprecated: false, + }); + expect((await sdk.index.getVersionDeployment({ chainId, version: "5.0.0" }))?.deployer).toBe( + INDEX_DTF_DEPLOYER_ADDRESS[chainId], + ); + }, + forkTestTimeout, + ); + + /** + * One bid with every number derived independently from contract reads: the price from the stored auction prices + * and window (exact at start and end, the exponential curve within PRICE_TOLERANCE in between), the size from the + * rebalance limits, weights, supply and balances exactly as Folio's getBid caps it. The pre-bid state is read one + * second earlier in the same fee day, so the supply is the one the bid sees. + */ + async function bidAndCheck(input: { + readonly auctionId: bigint; + readonly timestamp: bigint; + readonly take: readonly [bigint, bigint]; + readonly label: string; + }): Promise { + const { auctionId, timestamp, label } = input; + const window = await readAuctionWindow(auctionId); + const sellPrices = await readAuctionPrice(auctionId, wrappedNative); + const buyPrices = await readAuctionPrice(auctionId, stable); + const expected = expectedAuctionPrice(sellPrices, buyPrices, window, timestamp); + + if ((await publicClient.getBlock()).timestamp < timestamp - 1n) { + await setNextBlockTimestamp(timestamp - 1n); + await publicClient.request({ method: "evm_mine" as never, params: [] as never }); + } + const supply = await readTotalSupply(); + const rebalance = await readRebalance(); + const before = await readFolioBalances(); + const actorBefore = await readActorBalances(); + const traded = tradedByAuction.get(auctionId) ?? { sold: 0n, bought: 0n }; + const available = expectedSellAvailable({ balances: before, supply, rebalance, price: expected.price, traded }); + const sellAmount = (available * input.take[0]) / input.take[1]; + expect(sellAmount, `${label}: nothing left to sell`).toBeGreaterThan(0n); + const maxBuyAmount = expected.exact + ? ceilDiv(sellAmount * expected.price, D27) + : ceilDiv(sellAmount * expected.price * (PRICE_TOLERANCE_PARTS + 1n), D27 * PRICE_TOLERANCE_PARTS); + + await setNextBlockTimestamp(timestamp); + const receipt = await send( + ACTOR, + sdk.index.prepareBid({ + address: folio, + chainId, + version: "6.0.0", + auctionId, + sellToken: wrappedNative, + buyToken: stable, + sellAmount, + maxBuyAmount, + }), + { syncedFolioCall: true }, + ); + const blockNumber = receipt.blockNumber; + const [event] = parseEventLogs({ abi: folioV6Abi, eventName: "AuctionBid", logs: receipt.logs }); + if (!event) throw new Error(`${label}: no AuctionBid event`); + const [, , price] = await readBid(auctionId, 0n, blockNumber); + const [remainingSellAvailable] = await readBid(auctionId, maxUint256, blockNumber); + const after = await readFolioBalances(blockNumber); + const actorAfter = await readActorBalances(blockNumber); + const sold = event.args.sellAmount; + const bought = event.args.buyAmount; + + expect((await publicClient.getBlock({ blockNumber })).timestamp).toBe(timestamp); + expect(await readTotalSupply(blockNumber), `${label}: the fee day changed between the read and the bid`).toBe( + supply, + ); + if (expected.exact) { + expect(price).toBe(expected.price); + expect(bought).toBe(maxBuyAmount); + } else { + expect(Math.abs(Number(price) / expected.ideal - 1)).toBeLessThan(PRICE_TOLERANCE); + expect(bought).toBeLessThanOrEqual(maxBuyAmount); + } + expect(event.args).toMatchObject({ auctionId, sellToken: wrappedNative, buyToken: stable }); + expect(sold).toBe(sellAmount); + expect(bought).toBe(ceilDiv(sold * price, D27)); + expect(before.native - after.native).toBe(sold); + expect(after.stable - before.stable).toBe(bought); + expect(actorAfter.native - actorBefore.native).toBe(sold); + expect(actorBefore.stable - actorAfter.stable).toBe(bought); + + const tradedAfter = { sold: traded.sold + sold, bought: traded.bought + bought }; + tradedByAuction.set(auctionId, tradedAfter); + expect(remainingSellAvailable).toBe( + expectedSellAvailable({ balances: after, supply, rebalance, price, traded: tradedAfter }), + ); + + return { + label, + timestamp, + sellAmount: sold, + bidAmount: bought, + price, + remainingSellAvailable, + }; + } + + /** Folio's getBid cap (RebalancingLib.getBid) for selling the native token for the stable one. */ + function expectedSellAvailable(input: { + readonly balances: TokenBalances; + readonly supply: bigint; + readonly rebalance: Awaited>; + readonly price: bigint; + readonly traded: { readonly sold: bigint; readonly bought: bigint }; + }): bigint { + const { limits, tokens } = input.rebalance; + const sell = tokens.find((entry) => getAddress(entry.token) === wrappedNative)!; + const buy = tokens.find((entry) => getAddress(entry.token) === stable)!; + // Buy up to the low limit and low weight, within the auction's max size. + const buyLimitBalance = (((limits.low * buy.weight.low) / D18) * input.supply) / D27; + let buyAvailable = input.balances.stable < buyLimitBalance ? buyLimitBalance - input.balances.stable : 0n; + buyAvailable = minBigInt( + buyAvailable, + buy.maxAuctionSize > input.traded.bought ? buy.maxAuctionSize - input.traded.bought : 0n, + MAX_TOKEN_BUY_AMOUNT, + ); + // Sell down to the high limit and high weight, no more than the buy side can absorb at the price. + const sellLimitBalance = ceilDiv(ceilDiv(limits.high * sell.weight.high, D18) * input.supply, D27); + const sellAvailable = input.balances.native > sellLimitBalance ? input.balances.native - sellLimitBalance : 0n; + + return minBigInt( + sellAvailable, + (buyAvailable * D27) / input.price, + sell.maxAuctionSize > input.traded.sold ? sell.maxAuctionSize - input.traded.sold : 0n, + ); + } + + async function readRebalance(blockNumber?: bigint) { + const [nonce, , tokens, limits, timestamps] = await publicClient.readContract({ + address: folio, + abi: folioV6Abi, + functionName: "getRebalance", + blockNumber, + }); + + return { nonce, tokens, limits, timestamps }; + } + + async function readAuctionWindow(auctionId: bigint) { + const [, startTime, endTime] = await publicClient.readContract({ + address: folio, + abi: folioV6Abi, + functionName: "auctions", + args: [auctionId], + }); + + return { startTime, endTime }; + } + + async function readAuctionPrice(auctionId: bigint, token: Address): Promise { + const { low, high } = await publicClient.readContract({ + address: folio, + abi: folioV6Abi, + functionName: "getAuctionPrice", + args: [auctionId, token], + }); + + return { low, high }; + } + + async function readBid(auctionId: bigint, maxSellAmount: bigint, blockNumber: bigint) { + return publicClient.readContract({ + address: folio, + abi: folioV6Abi, + functionName: "getBid", + args: [auctionId, wrappedNative, stable, maxSellAmount], + blockNumber, + }); + } + + async function readTotalSupply(blockNumber?: bigint): Promise { + return publicClient.readContract({ address: folio, abi: folioV6Abi, functionName: "totalSupply", blockNumber }); + } + + async function readFolioBalances(blockNumber?: bigint): Promise { + return readBalances(folio, blockNumber); + } + + async function readActorBalances(blockNumber?: bigint): Promise { + return readBalances(ACTOR, blockNumber); + } + + async function readBalances(account: Address, blockNumber?: bigint): Promise { + const [native, stableBalance] = await publicClient.multicall({ + allowFailure: false, + blockNumber, + contracts: [ + { address: wrappedNative, abi: erc20Abi, functionName: "balanceOf", args: [account] }, + { address: stable, abi: erc20Abi, functionName: "balanceOf", args: [account] }, + ], + }); + + return { native, stable: stableBalance }; + } + + /** The fixture's fair price lies strictly inside the auction curve: above the end price, below the start price. */ + function expectPricesBracketFairPrice(sell: PriceRange, buy: PriceRange) { + // D27{stable/native} = {USD/native} * 10^stableDecimals * 1e27 / 1e18 + const fairPrice = (BigInt(fork.nativePrice) * 10n ** BigInt(stableDecimals) * D27) / D18; + + expect(ceilDiv(sell.high * D27, buy.low)).toBeGreaterThan(fairPrice); + expect(ceilDiv(sell.low * D27, buy.high)).toBeLessThan(fairPrice); + } + + function nativeValueShare(balances: TokenBalances): number { + const nativeValue = (Number(balances.native) / 1e18) * fork.nativePrice; + const stableValue = Number(balances.stable) / 10 ** stableDecimals; + + return nativeValue / (nativeValue + stableValue); + } + + /** eth_call outcome: returned, or reverted with its raw revert data ("0x" when the contract gave none). */ + async function probeCall( + to: Address, + data: Hex, + from: Address = ACTOR, + ): Promise<{ readonly reverted: false } | { readonly reverted: true; readonly data: Hex }> { + try { + await publicClient.call({ account: from, to, data }); + return { reverted: false }; + } catch (error) { + const raw = + error instanceof BaseError + ? (error.walk((cause) => typeof (cause as { data?: unknown }).data === "string") as { data?: Hex } | null) + : null; + if (!raw?.data) throw error; + + return { reverted: true, data: raw.data }; + } + } + + async function expectFolioRevert(from: Address, call: Pick, errorName: string) { + const result = await probeCall(call.to, call.data, from); + if (!result.reverted) throw new Error(`expected ${errorName}, but the call succeeded`); + expect(decodeErrorResult({ abi: folioV6Abi, data: result.data }).errorName).toBe(errorName); + } + + async function setNextBlockTimestamp(timestamp: bigint) { + await publicClient.request({ method: "evm_setNextBlockTimestamp" as never, params: [toHex(timestamp)] as never }); + } + + function getBasicDetails(name: string, symbol: string) { + // 0.2 native and its value in the stable at the fixture price: a 50/50 basket the rebalance moves to 20/80. + const nativeAmount = parseEther("0.2"); + const stableAmount = parseUnits(String(fork.nativePrice), stableDecimals) / 5n; + + return { + name, + symbol, + assets: [wrappedNative, stable], + amounts: [nativeAmount, stableAmount], + initialShares: parseEther("1"), + }; + } + + async function deployOptimisticStakingVault(): Promise
{ + const receipt = await send(ACTOR, { + chainId, + to: optimisticGovernorDeployer, + data: encodeFunctionData({ + abi: optimisticGovernorDeployerAbi, + functionName: "deployWithNewStakingVault", + args: [ + { + optimisticParams: { vetoDelay: 60, vetoPeriod: 300, vetoThreshold: parseEther("0.05") }, + standardParams: { + votingDelay: 60, + votingPeriod: 300, + voteExtension: 0, + proposalThreshold: parseEther("0.01"), + quorumNumerator: parseEther("0.01"), + }, + selectorData: [], + optimisticProposers: [], + additionalGuardians: [], + timelockDelay: 2n, + proposalThrottleCapacity: 10n, + }, + { underlying: wrappedNative, rewardTokens: [], rewardHalfLife: 604_800n, unstakingDelay: 604_800n }, + keccak256(toBytes(`fork-real-v6-vault-${chainId}`)), + ], + }), + value: 0n, + }); + const [system] = parseEventLogs({ + abi: optimisticGovernorDeployerAbi, + eventName: "ReserveOptimisticGovernorSystemDeployed", + logs: receipt.logs, + }); + if (!system) throw new Error("vote-lock vault deployment emitted no system event"); + + return getAddress(system.args.stakingVault); + } + + async function findRoleRegistryOwner(roleRegistry: Address): Promise
{ + for (const member of await readRoleMembers(roleRegistry, DEFAULT_ADMIN_ROLE)) { + const isOwner = await publicClient.readContract({ + address: roleRegistry, + abi: accessControlAbi, + functionName: "isOwner", + args: [member], + }); + if (isOwner) return member; + } + throw new Error(`no role registry owner found on chain ${chainId}`); + } + + async function readMutableFeeRecipients(address: Address) { + const recipients: { recipient: Address; portion: bigint }[] = []; + for (let index = 0n; ; index++) { + try { + const [recipient, portion] = await publicClient.readContract({ + address, + abi: folioV6Abi, + functionName: "feeRecipients", + args: [index], + }); + recipients.push({ recipient: getAddress(recipient), portion }); + } catch { + return recipients; + } + } + } + + async function readRoleMembers(address: Address, role: Hex): Promise { + const count = await publicClient.readContract({ + address, + abi: accessControlAbi, + functionName: "getRoleMemberCount", + args: [role], + }); + const members: Address[] = []; + for (let index = 0n; index < count; index++) { + members.push( + getAddress( + await publicClient.readContract({ + address, + abi: accessControlAbi, + functionName: "getRoleMember", + args: [role, index], + }), + ), + ); + } + return members; + } + + async function hasRoles(address: Address, role: Hex, accounts: readonly Address[]): Promise { + return publicClient.multicall({ + allowFailure: false, + contracts: accounts.map((account) => ({ + address, + abi: accessControlAbi, + functionName: "hasRole" as const, + args: [role, account] as const, + })), + }); + } + + async function getImplementation(proxy: Address): Promise
{ + const slot = await publicClient.getStorageAt({ address: proxy, slot: ERC1967_IMPLEMENTATION_SLOT }); + if (!slot) throw new Error(`${proxy} has no ERC-1967 implementation`); + + return getAddress(`0x${slot.slice(-40)}`); + } + + /** Finds the `balanceOf` mapping slot by probing, like forge's `deal`, and writes the balance there. */ + async function dealErc20(token: Address, account: Address, amount: bigint) { + for (let slot = 0n; slot < 64n; slot++) { + const key = keccak256(encodeAbiParameters([{ type: "address" }, { type: "uint256" }], [account, slot])); + const previous = (await publicClient.getStorageAt({ address: token, slot: key })) ?? zeroHash; + await publicClient.request({ + method: "anvil_setStorageAt" as never, + params: [token, key, toHex(amount, { size: 32 })] as never, + }); + const balance = await publicClient.readContract({ + address: token, + abi: erc20Abi, + functionName: "balanceOf", + args: [account], + }); + if (balance === amount) return; + await publicClient.request({ method: "anvil_setStorageAt" as never, params: [token, key, previous] as never }); + } + throw new Error(`no balanceOf slot found for ${token} on chain ${chainId}`); + } + + async function impersonate(account: Address) { + impersonated.add(account); + await publicClient.request({ method: "anvil_impersonateAccount" as never, params: [account] as never }); + await publicClient.request({ + method: "anvil_setBalance" as never, + params: [account, toHex(parseEther("1000"))] as never, + }); + } + + async function send( + from: Address, + call: Pick, + options: { readonly syncedFolioCall?: boolean } = {}, + ): Promise { + // Only Folio calls behind the `sync` modifier (startRebalance, openAuction, openAuctionUnrestricted, bid) get a + // fixed gas limit. Anvil estimates at one timestamp and can mine a second later; `sync` writes + // `lastFolioFeePoke = block.timestamp`, a no-op SSTORE in the estimate and a real one when mined, so a tight + // estimate starves the delegatecall into RebalancingLib and the call reverts with empty data (startRebalance + // reverts under a 590k limit while its success uses 587k). Everything else runs on the node's estimate. + const hash = await wallet.sendTransaction({ + account: from, + chain, + to: call.to, + data: call.data, + value: call.value, + ...(options.syncedFolioCall ? { gas: FORK_TRANSACTION_GAS } : {}), + }); + const receipt = await publicClient.waitForTransactionReceipt({ hash, pollingInterval: 250 }); + if (receipt.status !== "success") { + // The whole run is reverted in afterAll, so keep the revert data now: it is the only diagnosis left. + const trace = (await publicClient.request({ + method: "debug_traceTransaction" as never, + params: [hash, { tracer: "callTracer" }] as never, + })) as { readonly output?: Hex; readonly error?: string }; + throw new Error( + `transaction reverted on chain ${chainId}: ${hash} (${trace.error ?? "revert"} ${trace.output ?? "0x"})`, + ); + } + + return receipt; + } +}); + +function ceilDiv(numerator: bigint, denominator: bigint): bigint { + return (numerator + denominator - 1n) / denominator; +} + +/** Folio's auction price at `timestamp` (RebalancingLib._price): exact at start and end, exponential decay between. */ +function expectedAuctionPrice( + sell: PriceRange, + buy: PriceRange, + window: { readonly startTime: bigint; readonly endTime: bigint }, + timestamp: bigint, +): { readonly exact: boolean; readonly price: bigint; readonly ideal: number } { + const startPrice = ceilDiv(sell.high * D27, buy.low); + const endPrice = ceilDiv(sell.low * D27, buy.high); + if (timestamp === window.startTime) return { exact: true, price: startPrice, ideal: Number(startPrice) }; + if (timestamp === window.endTime) return { exact: true, price: endPrice, ideal: Number(endPrice) }; + + const progress = Number(timestamp - window.startTime) / Number(window.endTime - window.startTime); + const ideal = Number(startPrice) * Math.exp(Math.log(Number(endPrice) / Number(startPrice)) * progress); + + return { exact: false, price: BigInt(Math.ceil(ideal)), ideal }; +} + +/** A well-formed argument for a dispatch probe: every address is a real basket token, so token reads succeed. */ +function probeArgument(parameter: AbiParameter, token: Address): unknown { + const arrayMatch = parameter.type.match(/^(.*)\[(\d*)\]$/); + if (arrayMatch) { + const element = { ...parameter, type: arrayMatch[1]! } as AbiParameter; + return arrayMatch[2] ? Array.from({ length: Number(arrayMatch[2]) }, () => probeArgument(element, token)) : []; + } + if (parameter.type === "tuple") { + const components = (parameter as { readonly components: readonly AbiParameter[] }).components; + return Object.fromEntries(components.map((component) => [component.name, probeArgument(component, token)])); + } + if (parameter.type === "address") return token; + if (parameter.type === "bool") return false; + if (parameter.type === "string") return ""; + if (parameter.type === "bytes") return "0x"; + if (parameter.type.startsWith("bytes")) return toHex(0, { size: Number(parameter.type.slice(5)) }); + if (/^u?int\d*$/.test(parameter.type)) return 0n; + throw new Error(`no probe argument for ${parameter.type}`); +} + +function describeBid(bid: BidCheck): string { + return `${bid.label} t=${bid.timestamp}: sold ${bid.sellAmount} native for ${bid.bidAmount} stable at D27 ${bid.price}`; +} + +function minBigInt(...values: readonly bigint[]): bigint { + return values.reduce((min, value) => (value < min ? value : min)); +} diff --git a/packages/sdk/src/index-dtf/fork-smoke-fixture.test.ts b/packages/sdk/src/index-dtf/fork-smoke-fixture.test.ts new file mode 100644 index 0000000..9b941fe --- /dev/null +++ b/packages/sdk/src/index-dtf/fork-smoke-fixture.test.ts @@ -0,0 +1,407 @@ +import { encodeFunctionData, zeroAddress, type Address, type Hash, type Hex } from "viem"; +import { describe, expect, it } from "vitest"; + +import { dtfAdminProposalAbi } from "@/index-dtf/abis/dtf-admin-proposal"; +import { selectorRegistryAbi } from "@/index-dtf/abis/selector-registry"; +import { assertDisposableForkRpcUrl, parseForkSmokeManifest } from "@/index-dtf/fork-smoke-fixture"; + +const FOLIOS = { + control: "0x0000000000000000000000000000000000000001", + optimistic: "0x0000000000000000000000000000000000000002", + legacy: "0x0000000000000000000000000000000000000003", + native: "0x0000000000000000000000000000000000000004", +} as const satisfies Record; +const PROXY_ADMINS = { + control: "0x0000000000000000000000000000000000000011", + optimistic: "0x0000000000000000000000000000000000000012", + legacy: "0x0000000000000000000000000000000000000013", + native: "0x0000000000000000000000000000000000000014", +} as const satisfies Record; +const GOVERNANCES = { + optimistic: "0x0000000000000000000000000000000000000021", + legacy: "0x0000000000000000000000000000000000000022", + native: "0x0000000000000000000000000000000000000023", +} as const satisfies Record; +const TIMELOCK = "0x0000000000000000000000000000000000000031"; +const STAKING_VAULT = "0x0000000000000000000000000000000000000032"; +const SELECTOR_REGISTRY = "0x0000000000000000000000000000000000000033"; +const NATIVE_SELECTOR_REGISTRY = "0x0000000000000000000000000000000000000034"; +const UPGRADE_SPELL = "0x0000000000000000000000000000000000000041"; +const V5_START_REBALANCE_SELECTOR = "0x207c8eed"; +const V6_START_REBALANCE_SELECTOR = "0xc1e54b89"; + +const upgradeSpellEvidenceAbi = [ + { + type: "function", + name: "cast", + inputs: [ + { name: "folio", type: "address" }, + { name: "proxyAdmin", type: "address" }, + { name: "selectorRegistry", type: "address" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, +] as const; + +function createManifest() { + const optimisticCalldatas = [ + encodeFunctionData({ + abi: selectorRegistryAbi, + functionName: "registerSelectors", + args: [[{ target: FOLIOS.optimistic, selectors: [V6_START_REBALANCE_SELECTOR] }]], + }), + encodeFunctionData({ + abi: selectorRegistryAbi, + functionName: "unregisterSelectors", + args: [[{ target: FOLIOS.optimistic, selectors: [V5_START_REBALANCE_SELECTOR] }]], + }), + encodeFunctionData({ + abi: dtfAdminProposalAbi, + functionName: "transferOwnership", + args: [UPGRADE_SPELL], + }), + encodeFunctionData({ + abi: upgradeSpellEvidenceAbi, + functionName: "cast", + args: [FOLIOS.optimistic, PROXY_ADMINS.optimistic, SELECTOR_REGISTRY], + }), + ] as const; + const legacyCalldatas = [ + encodeFunctionData({ + abi: dtfAdminProposalAbi, + functionName: "transferOwnership", + args: [UPGRADE_SPELL], + }), + encodeFunctionData({ + abi: upgradeSpellEvidenceAbi, + functionName: "cast", + args: [FOLIOS.legacy, PROXY_ADMINS.legacy, zeroAddress], + }), + ] as const; + + return { + schemaVersion: 1, + rpcUrl: "http://127.0.0.1:8545", + chainId: 1, + forkBlock: "1", + stateBlock: "50", + stateTimestamp: "1000", + writePaths: { deadline: "2000", auctionLength: 300 }, + protocol: { + v5Deployer: FOLIOS.control, + v6Deployer: FOLIOS.native, + v6Implementation: PROXY_ADMINS.native, + versionRegistry: GOVERNANCES.native, + upgradeSpell: UPGRADE_SPELL, + upgradeSpellSourceCommit: "33c315690a71c826b5bcd01b69110f478ccd865d", + startRebalanceSelectors: { v5: V5_START_REBALANCE_SELECTOR, v6: V6_START_REBALANCE_SELECTOR }, + }, + scenarios: { + v5Control: { + folio: FOLIOS.control, + proxyAdmin: PROXY_ADMINS.control, + governance: zeroAddress, + governanceKind: "standard", + creationBlock: "10", + execution: { + actor: GOVERNANCES.optimistic, + roleAuthority: "direct-admin", + tokens: [FOLIOS.optimistic, FOLIOS.legacy], + rebalanceNonce: 1, + rebalanceTxHash: transactionHash(1), + rebalanceBlock: 41, + auctionId: 0, + auctionTxHash: transactionHash(2), + auctionBlock: 42, + auctionLength: 300, + }, + }, + v5OptimisticUpgrade: { + address: FOLIOS.optimistic, + proxyAdmin: PROXY_ADMINS.optimistic, + governance: GOVERNANCES.optimistic, + governanceKind: "optimistic", + governanceAddresses: governanceAddresses(GOVERNANCES.optimistic, SELECTOR_REGISTRY), + creationBlock: 20, + upgradeBlock: "30", + proposalCallCount: 4, + proposal: proposalEvidence( + 25, + 30, + [SELECTOR_REGISTRY, SELECTOR_REGISTRY, PROXY_ADMINS.optimistic, UPGRADE_SPELL], + optimisticCalldatas, + ), + }, + v5LegacyUpgrade: { + folio: FOLIOS.legacy, + proxyAdmin: PROXY_ADMINS.legacy, + governance: GOVERNANCES.legacy, + governanceKind: "standard", + governanceAddresses: governanceAddresses(GOVERNANCES.legacy, zeroAddress), + creationBlock: "21", + upgradeBlock: 31, + proposalCallCount: 2, + proposal: proposalEvidence(26, 31, [PROXY_ADMINS.legacy, UPGRADE_SPELL], legacyCalldatas), + }, + v6Native: { + folio: FOLIOS.native, + proxyAdmin: PROXY_ADMINS.native, + governance: GOVERNANCES.native, + governanceKind: "optimistic", + governanceAddresses: governanceAddresses(GOVERNANCES.native, NATIVE_SELECTOR_REGISTRY), + creationBlock: "40", + execution: { + actor: GOVERNANCES.optimistic, + roleAuthority: "optimistic-governance", + governanceMechanism: "optimistic", + authority: executionProposal("standard", false, 3, 41, 4, 42), + rebalanceProposal: { + ...executionProposal("optimistic", true, 5, 43, 6, 44), + deadline: 2000, + }, + tokens: [FOLIOS.optimistic, FOLIOS.legacy], + rebalanceNonce: 1, + deadline: 2000, + rebalanceTxHash: transactionHash(6), + rebalanceBlock: 44, + auctionId: 0, + auctionTxHash: transactionHash(7), + auctionBlock: 45, + auctionLength: 300, + }, + }, + }, + }; +} + +describe("assertDisposableForkRpcUrl", () => { + it("refuses the shared indexed sandbox and non-loopback RPCs", () => { + for (const url of ["http://127.0.0.1:8545", "http://localhost:8545/", "http://[::1]:8545"]) { + expect(() => assertDisposableForkRpcUrl(url)).toThrow("port 8545 is the shared indexed sandbox"); + } + expect(() => assertDisposableForkRpcUrl("https://base.gateway.tenderly.co")).toThrow("must use HTTP on localhost"); + expect(() => assertDisposableForkRpcUrl("http://10.0.0.2:8549")).toThrow("must use HTTP on localhost"); + }); + + it("refuses the manifest's indexed RPC on any port and accepts a separate disposable fork", () => { + expect(() => + assertDisposableForkRpcUrl("http://localhost:9545", { indexedRpcUrl: "http://127.0.0.1:9545" }), + ).toThrow("indexed sandbox RPC"); + expect(() => + assertDisposableForkRpcUrl("http://127.0.0.1:8546", { indexedRpcUrl: "http://127.0.0.1:8545" }), + ).not.toThrow(); + }); +}); + +describe("parseForkSmokeManifest", () => { + it("normalizes four distinct scenarios and documented address alias", () => { + const config = parseForkSmokeManifest(createManifest()); + + expect(config).toMatchObject({ + chainId: 1, + forkBlock: 1n, + stateBlock: 50n, + stateTimestamp: 1000n, + writePaths: { deadline: 2000n, auctionLength: 300n }, + rpcUrl: "http://127.0.0.1:8545", + protocol: { + v5Deployer: FOLIOS.control, + v6Deployer: FOLIOS.native, + v6Implementation: PROXY_ADMINS.native, + versionRegistry: GOVERNANCES.native, + upgradeSpell: UPGRADE_SPELL, + upgradeSpellSourceCommit: "33c315690a71c826b5bcd01b69110f478ccd865d", + }, + }); + expect(config.scenarios.map((scenario) => scenario.label)).toEqual([ + "v5Control", + "v5OptimisticUpgrade", + "v5LegacyUpgrade", + "v6Native", + ]); + expect(config.scenarios.map((scenario) => scenario.expectedVersion)).toEqual(["5.0.0", "6.0.0", "6.0.0", "6.0.0"]); + expect(config.scenarios[0].governance).toBeUndefined(); + expect(config.scenarios[0].execution).toMatchObject({ + roleAuthority: "direct-admin", + rebalanceNonce: 1n, + auctionId: 0n, + }); + expect(config.scenarios[1]).toMatchObject({ + governance: GOVERNANCES.optimistic, + governanceKind: "optimistic", + proposalCallCount: 4, + proposal: { proposalBlock: 25n, executeBlock: 30n }, + }); + expect(config.scenarios[3].execution).toMatchObject({ + roleAuthority: "optimistic-governance", + governanceMechanism: "optimistic", + authority: { kind: "standard", optimistic: false, proposalId: 3n }, + rebalanceProposal: { kind: "optimistic", optimistic: true, proposalId: 5n }, + deadline: 2000n, + }); + }); + + it("allows only a loopback RPC override", () => { + expect(parseForkSmokeManifest(createManifest(), "http://localhost:9545").rpcUrl).toBe("http://localhost:9545"); + expect(() => parseForkSmokeManifest(createManifest(), "https://eth-mainnet.example")).toThrow( + "rpcUrl must use HTTP on localhost", + ); + }); + + it("rejects unsupported schemas and incoherent transition blocks", () => { + expect(() => parseForkSmokeManifest({ ...createManifest(), schemaVersion: 2 })).toThrow("schemaVersion must be 1"); + expect(() => parseForkSmokeManifest({ ...createManifest(), forkBlock: 10 })).toThrow( + "every scenario creationBlock must be after forkBlock", + ); + const { v6Deployer: _v6Deployer, ...incompleteProtocol } = createManifest().protocol; + expect(() => parseForkSmokeManifest({ ...createManifest(), protocol: incompleteProtocol })).toThrow( + "protocol.v6Deployer", + ); + expect(() => + parseForkSmokeManifest({ + ...createManifest(), + scenarios: { + ...createManifest().scenarios, + v5Control: { ...createManifest().scenarios.v5Control, execution: undefined }, + }, + }), + ).toThrow("scenarios.v5Control.execution must be an object"); + expect(() => + parseForkSmokeManifest({ + ...createManifest(), + scenarios: { + ...createManifest().scenarios, + v5LegacyUpgrade: { + ...createManifest().scenarios.v5LegacyUpgrade, + creationBlock: "31", + upgradeBlock: "31", + }, + }, + }), + ).toThrow("creationBlock must be before its upgradeBlock"); + expect(() => + parseForkSmokeManifest({ + ...createManifest(), + scenarios: { + ...createManifest().scenarios, + v6Native: { ...createManifest().scenarios.v6Native, folio: zeroAddress }, + }, + }), + ).toThrow("must be a non-zero Ethereum address"); + }); + + it("rejects duplicate Folios and path evidence that does not match the scenario", () => { + expect(() => + parseForkSmokeManifest({ + ...createManifest(), + scenarios: { + ...createManifest().scenarios, + v6Native: { ...createManifest().scenarios.v6Native, folio: FOLIOS.control }, + }, + }), + ).toThrow("four distinct Folio addresses"); + + const manifest = createManifest(); + expect(() => + parseForkSmokeManifest({ + ...manifest, + scenarios: { + ...manifest.scenarios, + v5LegacyUpgrade: { + ...manifest.scenarios.v5LegacyUpgrade, + proposal: { + ...manifest.scenarios.v5LegacyUpgrade.proposal, + targets: [UPGRADE_SPELL, UPGRADE_SPELL], + }, + }, + }, + }), + ).toThrow("targets do not match the standard upgrade path"); + }); + + it("rejects execution evidence that collapses direct and optimistic mechanisms", () => { + const manifest = createManifest(); + expect(() => + parseForkSmokeManifest({ + ...manifest, + scenarios: { + ...manifest.scenarios, + v5Control: { + ...manifest.scenarios.v5Control, + execution: { + ...manifest.scenarios.v5Control.execution, + roleAuthority: "optimistic-governance", + }, + }, + }, + }), + ).toThrow("standard-topology direct-admin path"); + + expect(() => + parseForkSmokeManifest({ + ...manifest, + scenarios: { + ...manifest.scenarios, + v6Native: { + ...manifest.scenarios.v6Native, + execution: { + ...manifest.scenarios.v6Native.execution, + rebalanceTxHash: transactionHash(8), + }, + }, + }, + }), + ).toThrow("transaction linkage is inconsistent"); + }); +}); + +function governanceAddresses(governor: Address, selectorRegistry: Address) { + return { governor, timelock: TIMELOCK, stakingVault: STAKING_VAULT, selectorRegistry }; +} + +function executionProposal( + kind: "standard" | "optimistic", + optimistic: boolean, + proposalTx: number, + proposalBlock: number, + executeTx: number, + executeBlock: number, +) { + return { + kind, + optimistic, + state: "executed", + description: `${kind} execution evidence`, + proposalId: String(proposalTx), + proposalTxHash: transactionHash(proposalTx), + proposalBlock, + executeTxHash: transactionHash(executeTx), + executeBlock, + }; +} + +function transactionHash(value: number): Hash { + return `0x${value.toString(16).padStart(64, "0")}`; +} + +function proposalEvidence( + proposalBlock: number, + executeBlock: number, + targets: readonly Address[], + calldatas: readonly Hex[], +) { + return { + proposalId: "1", + entityId: "governance-1", + kind: "standard", + optimistic: false, + state: "executed", + proposalBlock, + executeBlock, + targets, + values: targets.map(() => 0), + calldatas, + selectors: calldatas.map((calldata) => calldata.slice(0, 10) as Hex), + }; +} diff --git a/packages/sdk/src/index-dtf/fork-smoke-fixture.ts b/packages/sdk/src/index-dtf/fork-smoke-fixture.ts new file mode 100644 index 0000000..3d2ad24 --- /dev/null +++ b/packages/sdk/src/index-dtf/fork-smoke-fixture.ts @@ -0,0 +1,766 @@ +/// + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { decodeFunctionData, getAddress, isHex, zeroAddress, type Address, type Hash, type Hex } from "viem"; + +import type { SupportedChainId } from "@/config"; + +import { dtfAdminProposalAbi } from "@/index-dtf/abis/dtf-admin-proposal"; +import { selectorRegistryAbi } from "@/index-dtf/abis/selector-registry"; +import { upgradeSpell600Abi } from "@/index-dtf/abis/upgrade-spell-6-0-0"; + +export type ForkUpgradeProposalEvidence = { + readonly proposalId: bigint; + readonly proposalBlock: bigint; + readonly executeBlock: bigint; + readonly targets: readonly Address[]; + readonly values: readonly bigint[]; + readonly calldatas: readonly Hex[]; + readonly selectors: readonly Hex[]; +}; + +export type ForkExecutedGovernanceEvidence = { + readonly kind: "standard" | "optimistic"; + readonly optimistic: boolean; + readonly description: string; + readonly proposalId: bigint; + readonly proposalTxHash: Hash; + readonly proposalBlock: bigint; + readonly executeTxHash: Hash; + readonly executeBlock: bigint; +}; + +type ForkExecutionState = { + readonly actor: Address; + readonly tokens: readonly Address[]; + readonly rebalanceNonce: bigint; + readonly rebalanceTxHash: Hash; + readonly rebalanceBlock: bigint; + readonly auctionId: bigint; + readonly auctionTxHash: Hash; + readonly auctionBlock: bigint; + readonly auctionLength: bigint; +}; + +export type ForkV5ExecutionEvidence = ForkExecutionState & { + readonly roleAuthority: "direct-admin"; +}; + +export type ForkV6ExecutionEvidence = ForkExecutionState & { + readonly roleAuthority: "optimistic-governance"; + readonly governanceMechanism: "optimistic"; + /** Absent when the sandbox found the roles already granted and skipped the proposal (recorded as id 0). */ + readonly authority?: ForkExecutedGovernanceEvidence & { readonly kind: "standard"; readonly optimistic: false }; + readonly rebalanceProposal: ForkExecutedGovernanceEvidence & { + readonly kind: "optimistic"; + readonly optimistic: true; + }; + readonly deadline: bigint; +}; + +export type ForkExecutionEvidence = ForkV5ExecutionEvidence | ForkV6ExecutionEvidence; + +export type ForkScenario = { + readonly label: "v5Control" | "v5OptimisticUpgrade" | "v5LegacyUpgrade" | "v6Native"; + readonly folio: Address; + readonly proxyAdmin: Address; + readonly governance?: Address; + readonly governanceKind: "standard" | "optimistic"; + readonly governanceAddresses?: { + readonly governor: Address; + readonly timelock: Address; + readonly stakingVault: Address; + readonly selectorRegistry: Address; + }; + readonly creationBlock: bigint; + readonly upgradeBlock?: bigint; + readonly proposalCallCount?: number; + readonly proposal?: ForkUpgradeProposalEvidence; + readonly execution?: ForkExecutionEvidence; + readonly expectedVersion: "5.0.0" | "6.0.0"; +}; + +export type ForkSmokeConfig = { + readonly chainId: SupportedChainId; + readonly rpcUrl: string; + readonly forkBlock: bigint; + readonly stateBlock: bigint; + readonly stateTimestamp: bigint; + readonly writePaths: { + readonly deadline: bigint; + readonly auctionLength: bigint; + }; + readonly protocol: { + readonly v5Deployer: Address; + readonly v6Deployer: Address; + readonly v6Implementation: Address; + readonly versionRegistry: Address; + readonly upgradeSpell: Address; + readonly upgradeSpellSourceCommit: string; + readonly startRebalanceSelectors: { readonly "5.0.0": Hex; readonly "6.0.0": Hex }; + }; + readonly scenarios: readonly [ForkScenario, ForkScenario, ForkScenario, ForkScenario]; +}; + +type RuntimeEnv = Readonly>; + +type FixtureRecord = Readonly>; + +export function readForkSmokeConfig(env: RuntimeEnv): ForkSmokeConfig { + const manifestPath = resolveManifestPath(env); + let manifest: unknown; + + try { + manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch (error) { + throw new Error(`Unable to read INDEX_DTF_FORK_MANIFEST at ${manifestPath}`, { cause: error }); + } + + return parseForkSmokeManifest(manifest, env.INDEX_DTF_FORK_RPC_URL); +} + +export function parseForkSmokeManifest(input: unknown, rpcUrlOverride?: string): ForkSmokeConfig { + const manifest = readRecord(input, "manifest"); + if (manifest.schemaVersion !== 1) throw new Error("fixture manifest schemaVersion must be 1"); + + const chainId = readChainId(manifest.chainId); + const forkBlock = readBlock(manifest.forkBlock, "forkBlock"); + const stateBlock = readBlock(manifest.stateBlock, "stateBlock"); + if (forkBlock >= stateBlock) throw new Error("forkBlock must be before stateBlock"); + const stateTimestamp = readPositiveInteger(manifest.stateTimestamp, "stateTimestamp"); + const writePaths = readRecord(manifest.writePaths, "writePaths"); + const deadline = readPositiveInteger(writePaths.deadline, "writePaths.deadline"); + const auctionLength = readPositiveInteger(writePaths.auctionLength, "writePaths.auctionLength"); + if (deadline <= stateTimestamp) throw new Error("writePaths.deadline must be after stateTimestamp"); + const protocol = readRecord(manifest.protocol, "protocol"); + const v5Deployer = readAddress(protocol.v5Deployer, "protocol.v5Deployer"); + const v6Deployer = readAddress(protocol.v6Deployer, "protocol.v6Deployer"); + const v6Implementation = readAddress(protocol.v6Implementation, "protocol.v6Implementation"); + const versionRegistry = readAddress(protocol.versionRegistry, "protocol.versionRegistry"); + const upgradeSpell = readAddress(protocol.upgradeSpell, "protocol.upgradeSpell"); + const upgradeSpellSourceCommit = readGitCommit( + protocol.upgradeSpellSourceCommit, + "protocol.upgradeSpellSourceCommit", + ); + const startRebalanceSelectors = readRecord(protocol.startRebalanceSelectors, "protocol.startRebalanceSelectors"); + const v5StartRebalanceSelector = readSelector(startRebalanceSelectors.v5, "protocol.startRebalanceSelectors.v5"); + const v6StartRebalanceSelector = readSelector(startRebalanceSelectors.v6, "protocol.startRebalanceSelectors.v6"); + const scenarios = readRecord(manifest.scenarios, "scenarios"); + const evidence = { + upgradeSpell, + v5StartRebalanceSelector, + v6StartRebalanceSelector, + stateBlock, + writePathDeadline: deadline, + writePathAuctionLength: auctionLength, + }; + const parsedScenarios = [ + readScenario(scenarios, "v5Control", "5.0.0", "standard", false, false, "v5-direct", evidence), + readScenario(scenarios, "v5OptimisticUpgrade", "6.0.0", "optimistic", true, true, undefined, evidence), + readScenario(scenarios, "v5LegacyUpgrade", "6.0.0", "standard", true, true, undefined, evidence), + readScenario(scenarios, "v6Native", "6.0.0", "optimistic", true, false, "v6-optimistic", evidence), + ] as const; + if (new Set(parsedScenarios.map((scenario) => scenario.folio.toLowerCase())).size !== parsedScenarios.length) { + throw new Error("fixture scenarios must use four distinct Folio addresses"); + } + if (parsedScenarios.some((scenario) => scenario.creationBlock <= forkBlock)) { + throw new Error("every scenario creationBlock must be after forkBlock"); + } + const latestTransitionBlock = parsedScenarios.reduce( + (latest, scenario) => maxBigInt(latest, scenario.creationBlock, scenario.upgradeBlock ?? 0n), + 0n, + ); + + if (stateBlock < latestTransitionBlock) { + throw new Error("stateBlock must be at or after every scenario transition block"); + } + + const rpcUrl = rpcUrlOverride?.trim() || readString(manifest.rpcUrl, "rpcUrl"); + assertLocalRpcUrl(rpcUrl); + + return { + chainId, + rpcUrl, + forkBlock, + stateBlock, + stateTimestamp, + writePaths: { deadline, auctionLength }, + protocol: { + v5Deployer, + v6Deployer, + v6Implementation, + versionRegistry, + upgradeSpell, + upgradeSpellSourceCommit, + startRebalanceSelectors: { "5.0.0": v5StartRebalanceSelector, "6.0.0": v6StartRebalanceSelector }, + }, + scenarios: parsedScenarios, + }; +} + +function resolveManifestPath(env: RuntimeEnv): string { + const explicitPath = env.INDEX_DTF_FORK_MANIFEST?.trim(); + if (explicitPath) return resolve(explicitPath); + + const stateDirectory = env.SANDBOX_STATE_DIR?.trim(); + if (stateDirectory) return resolve(stateDirectory, "fixture.json"); + + throw new Error("INDEX_DTF_FORK_MANIFEST or SANDBOX_STATE_DIR is required when RUN_INDEX_DTF_FORK_SMOKE=1"); +} + +function readScenario( + scenarios: FixtureRecord, + label: ForkScenario["label"], + expectedVersion: ForkScenario["expectedVersion"], + expectedGovernanceKind: ForkScenario["governanceKind"], + requiresGovernance: boolean, + requiresUpgrade: boolean, + executionKind: "v5-direct" | "v6-optimistic" | undefined, + evidence: { + readonly upgradeSpell: Address; + readonly v5StartRebalanceSelector: Hex; + readonly v6StartRebalanceSelector: Hex; + readonly stateBlock: bigint; + readonly writePathDeadline: bigint; + readonly writePathAuctionLength: bigint; + }, +): ForkScenario { + const scenario = readRecord(scenarios[label], `scenarios.${label}`); + const folio = readAddress(scenario.folio ?? scenario.address, `scenarios.${label}.folio`); + const proxyAdmin = readAddress(scenario.proxyAdmin, `scenarios.${label}.proxyAdmin`); + const governanceKind = readString(scenario.governanceKind, `scenarios.${label}.governanceKind`); + if (governanceKind !== expectedGovernanceKind) { + throw new Error(`scenarios.${label}.governanceKind must be ${expectedGovernanceKind}`); + } + const creationBlock = readBlock(scenario.creationBlock, `scenarios.${label}.creationBlock`); + const upgradeBlock = requiresUpgrade + ? readBlock(scenario.upgradeBlock, `scenarios.${label}.upgradeBlock`) + : undefined; + const proposalCallCount = readOptionalPositiveSafeInteger( + scenario.proposalCallCount, + `scenarios.${label}.proposalCallCount`, + ); + + if (upgradeBlock !== undefined && creationBlock >= upgradeBlock) { + throw new Error(`scenarios.${label}.creationBlock must be before its upgradeBlock`); + } + + const governance = readScenarioGovernance(scenario, label, governanceKind, requiresGovernance); + const proposal = + upgradeBlock === undefined + ? undefined + : readUpgradeProposalEvidence(scenario, { + label, + folio, + proxyAdmin, + governanceKind, + creationBlock, + upgradeBlock, + proposalCallCount, + ...governance, + ...evidence, + }); + const execution = + executionKind === undefined + ? undefined + : readExecutionEvidence(scenario, { + label, + creationBlock, + governanceKind, + executionKind, + ...evidence, + }); + + return { + label, + folio, + proxyAdmin, + governanceKind, + ...(governance.governance ? { governance: governance.governance } : {}), + ...(governance.governanceAddresses ? { governanceAddresses: governance.governanceAddresses } : {}), + creationBlock, + ...(upgradeBlock === undefined ? {} : { upgradeBlock }), + ...(proposalCallCount === undefined ? {} : { proposalCallCount }), + ...(proposal === undefined ? {} : { proposal }), + ...(execution === undefined ? {} : { execution }), + expectedVersion, + }; +} + +function readExecutionEvidence( + scenario: FixtureRecord, + context: { + readonly label: ForkScenario["label"]; + readonly creationBlock: bigint; + readonly governanceKind: ForkScenario["governanceKind"]; + readonly executionKind: "v5-direct" | "v6-optimistic"; + readonly stateBlock: bigint; + readonly writePathDeadline: bigint; + readonly writePathAuctionLength: bigint; + }, +): ForkExecutionEvidence { + const field = `scenarios.${context.label}.execution`; + const execution = readRecord(scenario.execution, field); + const actor = readAddress(execution.actor, `${field}.actor`); + const tokens = readArray(execution.tokens, `${field}.tokens`).map((value, index) => + readAddress(value, `${field}.tokens[${index}]`), + ); + if (tokens.length < 2 || new Set(tokens.map((token) => token.toLowerCase())).size !== tokens.length) { + throw new Error(`${field}.tokens must contain at least two distinct addresses`); + } + const rebalanceNonce = readPositiveInteger(execution.rebalanceNonce, `${field}.rebalanceNonce`); + if (rebalanceNonce !== 1n) throw new Error(`${field}.rebalanceNonce must be 1`); + const rebalanceTxHash = readHash(execution.rebalanceTxHash, `${field}.rebalanceTxHash`); + const rebalanceBlock = readBlock(execution.rebalanceBlock, `${field}.rebalanceBlock`); + const auctionId = readNonNegativeInteger(execution.auctionId, `${field}.auctionId`); + if (auctionId !== 0n) throw new Error(`${field}.auctionId must be 0`); + const auctionTxHash = readHash(execution.auctionTxHash, `${field}.auctionTxHash`); + const auctionBlock = readBlock(execution.auctionBlock, `${field}.auctionBlock`); + const auctionLength = readPositiveInteger(execution.auctionLength, `${field}.auctionLength`); + if (auctionLength !== context.writePathAuctionLength || auctionLength !== 300n) { + throw new Error(`${field}.auctionLength must match writePaths.auctionLength and equal 300`); + } + if (context.creationBlock >= rebalanceBlock || rebalanceBlock >= auctionBlock || auctionBlock > context.stateBlock) { + throw new Error(`${field} blocks must satisfy creationBlock < rebalanceBlock < auctionBlock <= stateBlock`); + } + if (rebalanceTxHash.toLowerCase() === auctionTxHash.toLowerCase()) { + throw new Error(`${field} rebalance and auction transactions must differ`); + } + + const common = { + actor, + tokens, + rebalanceNonce, + rebalanceTxHash, + rebalanceBlock, + auctionId, + auctionTxHash, + auctionBlock, + auctionLength, + } as const; + + if (context.executionKind === "v5-direct") { + if (execution.roleAuthority !== "direct-admin" || context.governanceKind !== "standard") { + throw new Error(`${field} must describe the standard-topology direct-admin path`); + } + if ( + execution.governanceMechanism !== undefined || + execution.authority !== undefined || + execution.rebalanceProposal !== undefined + ) { + throw new Error(`${field} direct-admin path must not contain governance proposal evidence`); + } + + return { ...common, roleAuthority: "direct-admin" }; + } + + if ( + execution.roleAuthority !== "optimistic-governance" || + execution.governanceMechanism !== "optimistic" || + context.governanceKind !== "optimistic" + ) { + throw new Error(`${field} must describe the optimistic-governance path`); + } + const authorityRecord = readRecord(execution.authority, `${field}.authority`); + const authority = + String(authorityRecord.proposalId) === "0" + ? undefined + : readExecutedGovernanceEvidence(execution.authority, `${field}.authority`, "standard", false); + const rebalanceProposal = readExecutedGovernanceEvidence( + execution.rebalanceProposal, + `${field}.rebalanceProposal`, + "optimistic", + true, + ); + const deadline = readPositiveInteger(execution.deadline, `${field}.deadline`); + const proposalRecord = readRecord(execution.rebalanceProposal, `${field}.rebalanceProposal`); + const proposalDeadline = readPositiveInteger(proposalRecord.deadline, `${field}.rebalanceProposal.deadline`); + if (deadline !== proposalDeadline || deadline !== context.writePathDeadline) { + throw new Error(`${field} deadlines must match writePaths.deadline`); + } + if ( + context.creationBlock >= rebalanceProposal.proposalBlock || + rebalanceProposal.proposalBlock >= rebalanceProposal.executeBlock || + rebalanceProposal.executeBlock !== rebalanceBlock || + rebalanceProposal.executeTxHash.toLowerCase() !== rebalanceTxHash.toLowerCase() + ) { + throw new Error(`${field} governance, rebalance, and transaction linkage is inconsistent`); + } + if (authority !== undefined) { + if ( + context.creationBlock >= authority.proposalBlock || + authority.proposalBlock >= authority.executeBlock || + authority.executeBlock >= rebalanceProposal.proposalBlock + ) { + throw new Error(`${field} authority proposal must sit between creation and the rebalance proposal`); + } + if ( + authority.proposalId === rebalanceProposal.proposalId || + authority.proposalTxHash.toLowerCase() === rebalanceProposal.proposalTxHash.toLowerCase() + ) { + throw new Error(`${field} authority and rebalance proposals must be distinct`); + } + } + + return { + ...common, + roleAuthority: "optimistic-governance", + governanceMechanism: "optimistic", + ...(authority ? { authority: { ...authority, kind: "standard" as const, optimistic: false as const } } : {}), + rebalanceProposal: { ...rebalanceProposal, kind: "optimistic", optimistic: true }, + deadline, + }; +} + +function readExecutedGovernanceEvidence( + value: unknown, + field: string, + kind: ForkExecutedGovernanceEvidence["kind"], + optimistic: boolean, +): ForkExecutedGovernanceEvidence { + const proposal = readRecord(value, field); + if (proposal.kind !== kind || proposal.optimistic !== optimistic || proposal.state !== "executed") { + throw new Error(`${field} must describe an executed ${kind} proposal`); + } + const description = readString(proposal.description, `${field}.description`); + + return { + kind, + optimistic, + description, + proposalId: readPositiveInteger(proposal.proposalId, `${field}.proposalId`), + proposalTxHash: readHash(proposal.proposalTxHash, `${field}.proposalTxHash`), + proposalBlock: readBlock(proposal.proposalBlock, `${field}.proposalBlock`), + executeTxHash: readHash(proposal.executeTxHash, `${field}.executeTxHash`), + executeBlock: readBlock(proposal.executeBlock, `${field}.executeBlock`), + }; +} + +function readScenarioGovernance( + scenario: FixtureRecord, + label: ForkScenario["label"], + governanceKind: ForkScenario["governanceKind"], + required: boolean, +): { + readonly governance?: Address; + readonly selectorRegistry?: Address; + readonly governanceAddresses?: NonNullable; +} { + if (!required && (scenario.governance === undefined || scenario.governance === zeroAddress)) return {}; + + const governance = readAddress(scenario.governance, `scenarios.${label}.governance`); + const addresses = readRecord(scenario.governanceAddresses, `scenarios.${label}.governanceAddresses`); + const governor = readAddress(addresses.governor, `scenarios.${label}.governanceAddresses.governor`); + const selectorRegistry = readEthereumAddress( + addresses.selectorRegistry, + `scenarios.${label}.governanceAddresses.selectorRegistry`, + ); + const timelock = readAddress(addresses.timelock, `scenarios.${label}.governanceAddresses.timelock`); + const stakingVault = readAddress(addresses.stakingVault, `scenarios.${label}.governanceAddresses.stakingVault`); + + if (governor !== governance) throw new Error(`scenarios.${label} governance must equal governanceAddresses.governor`); + if (governanceKind === "optimistic" && selectorRegistry === zeroAddress) { + throw new Error(`scenarios.${label} optimistic governance requires a non-zero selectorRegistry`); + } + if (governanceKind === "standard" && selectorRegistry !== zeroAddress) { + throw new Error(`scenarios.${label} standard governance requires a zero selectorRegistry`); + } + + return { + governance, + selectorRegistry, + governanceAddresses: { governor, timelock, stakingVault, selectorRegistry }, + }; +} + +function readUpgradeProposalEvidence( + scenario: FixtureRecord, + context: { + readonly label: ForkScenario["label"]; + readonly folio: Address; + readonly proxyAdmin: Address; + readonly governanceKind: ForkScenario["governanceKind"]; + readonly governance?: Address; + readonly selectorRegistry?: Address; + readonly creationBlock: bigint; + readonly upgradeBlock: bigint; + readonly proposalCallCount: number | undefined; + readonly upgradeSpell: Address; + readonly v5StartRebalanceSelector: Hex; + readonly v6StartRebalanceSelector: Hex; + }, +): ForkUpgradeProposalEvidence { + const field = `scenarios.${context.label}.proposal`; + const proposal = readRecord(scenario.proposal, field); + if (proposal.kind !== "standard" || proposal.optimistic !== false || proposal.state !== "executed") { + throw new Error(`${field} must describe an executed standard proposal`); + } + const proposalId = readPositiveInteger(proposal.proposalId, `${field}.proposalId`); + readString(proposal.entityId, `${field}.entityId`); + const proposalBlock = readBlock(proposal.proposalBlock, `${field}.proposalBlock`); + const executeBlock = readBlock(proposal.executeBlock, `${field}.executeBlock`); + if ( + proposalBlock <= context.creationBlock || + proposalBlock >= executeBlock || + executeBlock !== context.upgradeBlock + ) { + throw new Error(`${field} blocks must satisfy creationBlock < proposalBlock < executeBlock == upgradeBlock`); + } + + const callCount = context.proposalCallCount; + if (callCount === undefined) throw new Error(`scenarios.${context.label}.proposalCallCount is required`); + const targets = readArray(proposal.targets, `${field}.targets`).map((value, index) => + readAddress(value, `${field}.targets[${index}]`), + ); + const values = readArray(proposal.values, `${field}.values`).map((value, index) => + readNonNegativeInteger(value, `${field}.values[${index}]`), + ); + const calldatas = readArray(proposal.calldatas, `${field}.calldatas`).map((value, index) => + readHex(value, `${field}.calldatas[${index}]`), + ); + const selectors = readArray(proposal.selectors, `${field}.selectors`).map((value, index) => + readSelector(value, `${field}.selectors[${index}]`), + ); + + if ([targets.length, values.length, calldatas.length, selectors.length].some((length) => length !== callCount)) { + throw new Error(`${field} call arrays must match proposalCallCount`); + } + if (values.some((value) => value !== 0n)) throw new Error(`${field}.values must all be zero`); + for (let index = 0; index < selectors.length; index += 1) { + if (calldatas[index]!.slice(0, 10).toLowerCase() !== selectors[index]!.toLowerCase()) { + throw new Error(`${field}.selectors[${index}] must match its calldata selector`); + } + } + + assertUpgradeCallPath({ ...context, targets, calldatas }); + + return { proposalId, proposalBlock, executeBlock, targets, values, calldatas, selectors }; +} + +function assertUpgradeCallPath(context: { + readonly label: ForkScenario["label"]; + readonly folio: Address; + readonly proxyAdmin: Address; + readonly governanceKind: ForkScenario["governanceKind"]; + readonly selectorRegistry?: Address; + readonly upgradeSpell: Address; + readonly v5StartRebalanceSelector: Hex; + readonly v6StartRebalanceSelector: Hex; + readonly targets: readonly Address[]; + readonly calldatas: readonly Hex[]; +}) { + const { label, targets, calldatas } = context; + const expectedTargets = + context.governanceKind === "optimistic" + ? [context.selectorRegistry, context.selectorRegistry, context.proxyAdmin, context.upgradeSpell] + : [context.proxyAdmin, context.upgradeSpell]; + if ( + targets.length !== expectedTargets.length || + targets.some((target, index) => target.toLowerCase() !== expectedTargets[index]?.toLowerCase()) + ) { + throw new Error(`scenarios.${label}.proposal targets do not match the ${context.governanceKind} upgrade path`); + } + + let callIndex = 0; + if (context.governanceKind === "optimistic") { + const registered = decodeFunctionData({ abi: selectorRegistryAbi, data: calldatas[callIndex++]! }); + const unregistered = decodeFunctionData({ abi: selectorRegistryAbi, data: calldatas[callIndex++]! }); + expectSelectorChange(registered, "registerSelectors", context.folio, context.v6StartRebalanceSelector, label); + expectSelectorChange(unregistered, "unregisterSelectors", context.folio, context.v5StartRebalanceSelector, label); + } + + const transfer = decodeFunctionData({ abi: dtfAdminProposalAbi, data: calldatas[callIndex++]! }); + if (transfer.functionName !== "transferOwnership" || transfer.args[0] !== context.upgradeSpell) { + throw new Error(`scenarios.${label}.proposal must transfer ProxyAdmin ownership to the upgrade spell`); + } + const cast = decodeFunctionData({ abi: upgradeSpell600Abi, data: calldatas[callIndex]! }); + const expectedSelectorRegistry = context.governanceKind === "optimistic" ? context.selectorRegistry : zeroAddress; + if ( + cast.functionName !== "cast" || + cast.args[0] !== context.folio || + cast.args[1] !== context.proxyAdmin || + cast.args[2] !== expectedSelectorRegistry + ) { + throw new Error(`scenarios.${label}.proposal cast calldata does not match the declared upgrade path`); + } +} + +function expectSelectorChange( + decoded: ReturnType, + functionName: "registerSelectors" | "unregisterSelectors", + folio: Address, + selector: Hex, + label: ForkScenario["label"], +) { + if (decoded.functionName !== functionName) { + throw new Error(`scenarios.${label}.proposal must call ${functionName}`); + } + const selectorData = decoded.args[0] as readonly { readonly target: Address; readonly selectors: readonly Hex[] }[]; + if ( + selectorData.length !== 1 || + selectorData[0]?.target !== folio || + selectorData[0]?.selectors.length !== 1 || + selectorData[0]?.selectors[0]?.toLowerCase() !== selector.toLowerCase() + ) { + throw new Error(`scenarios.${label}.proposal ${functionName} calldata does not match the Folio selector`); + } +} + +function readRecord(value: unknown, field: string): FixtureRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${field} must be an object`); + } + + return value as FixtureRecord; +} + +function readArray(value: unknown, field: string): readonly unknown[] { + if (!Array.isArray(value)) throw new Error(`${field} must be an array`); + + return value; +} + +function readString(value: unknown, field: string): string { + if (typeof value !== "string" || value.trim() === "") throw new Error(`${field} must be a non-empty string`); + + return value.trim(); +} + +function readAddress(value: unknown, field: string): Address { + const address = readEthereumAddress(value, field); + if (address === zeroAddress) throw new Error(`${field} must be a non-zero Ethereum address`); + + return address; +} + +function readEthereumAddress(value: unknown, field: string): Address { + try { + return getAddress(readString(value, field)); + } catch (error) { + throw new Error(`${field} must be an Ethereum address`, { cause: error }); + } +} + +function readHex(value: unknown, field: string): Hex { + if (typeof value !== "string" || !isHex(value) || value.length < 10) throw new Error(`${field} must be calldata hex`); + + return value; +} + +function readSelector(value: unknown, field: string): Hex { + const selector = readHex(value, field); + if (selector.length !== 10) throw new Error(`${field} must be a bytes4 selector`); + + return selector; +} + +function readHash(value: unknown, field: string): Hash { + const hash = readHex(value, field); + if (hash.length !== 66) throw new Error(`${field} must be a bytes32 transaction hash`); + + return hash; +} + +function readGitCommit(value: unknown, field: string): string { + const commit = readString(value, field); + if (!/^[0-9a-f]{40}$/i.test(commit)) throw new Error(`${field} must be a full Git commit hash`); + + return commit.toLowerCase(); +} + +function readBlock(value: unknown, field: string): bigint { + return readPositiveInteger(value, field); +} + +function readPositiveInteger(value: unknown, field: string): bigint { + if (typeof value !== "string" && typeof value !== "number") { + throw new Error(`${field} must be a positive integer string or safe integer`); + } + if (typeof value === "number" && (!Number.isSafeInteger(value) || value <= 0)) { + throw new Error(`${field} must be a positive safe integer`); + } + + try { + const block = BigInt(value); + if (block <= 0n) throw new Error(); + return block; + } catch { + throw new Error(`${field} must be a positive integer`); + } +} + +function readNonNegativeInteger(value: unknown, field: string): bigint { + if (typeof value !== "string" && typeof value !== "number") { + throw new Error(`${field} must be a non-negative integer string or safe integer`); + } + if (typeof value === "number" && (!Number.isSafeInteger(value) || value < 0)) { + throw new Error(`${field} must be a non-negative safe integer`); + } + + try { + const number = BigInt(value); + if (number < 0n) throw new Error(); + return number; + } catch { + throw new Error(`${field} must be a non-negative integer`); + } +} + +function readOptionalPositiveSafeInteger(value: unknown, field: string): number | undefined { + if (value === undefined) return undefined; + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${field} must be a positive safe integer`); + } + + return value; +} + +function readChainId(value: unknown): SupportedChainId { + if (value === 1 || value === 8453 || value === 56) return value; + + throw new Error("chainId must be 1, 8453, or 56"); +} + +/** + * Mutating fork suites (snapshots, reverts, time warps, impersonated writes) run only on a disposable loopback Anvil. + * Port 8545 is the shared sandbox a fork Graph Node indexes: rewinding it under the indexer corrupts the stack. + * `indexedRpcUrl` (the sandbox manifest's RPC) is refused too, whatever its port. + */ +export function assertDisposableForkRpcUrl(value: string, options: { readonly indexedRpcUrl?: string } = {}) { + const url = new URL(value); + + if (url.protocol !== "http:" || !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) { + throw new Error("disposable fork RPC must use HTTP on localhost, 127.0.0.1, or ::1"); + } + if ((url.port || "80") === "8545") { + throw new Error("port 8545 is the shared indexed sandbox; run mutating fork suites on a disposable fork"); + } + if (options.indexedRpcUrl && sameLoopbackEndpoint(url, new URL(options.indexedRpcUrl))) { + throw new Error(`${value} is the indexed sandbox RPC from the manifest; point the suite at a disposable fork`); + } +} + +function sameLoopbackEndpoint(left: URL, right: URL): boolean { + const loopback = (hostname: string) => (hostname === "localhost" || hostname === "[::1]" ? "127.0.0.1" : hostname); + + return loopback(left.hostname) === loopback(right.hostname) && (left.port || "80") === (right.port || "80"); +} + +function assertLocalRpcUrl(value: string) { + let url: URL; + + try { + url = new URL(value); + } catch { + throw new Error("rpcUrl must be a valid URL"); + } + + if (url.protocol !== "http:" || !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) { + throw new Error("rpcUrl must use HTTP on localhost, 127.0.0.1, or ::1"); + } +} + +function maxBigInt(...values: readonly bigint[]): bigint { + return values.reduce((max, value) => (value > max ? value : max), 0n); +} diff --git a/packages/sdk/src/index-dtf/fork-smoke-v6-mutating.test.ts b/packages/sdk/src/index-dtf/fork-smoke-v6-mutating.test.ts new file mode 100644 index 0000000..d819513 --- /dev/null +++ b/packages/sdk/src/index-dtf/fork-smoke-v6-mutating.test.ts @@ -0,0 +1,351 @@ +import { + createPublicClient, + createWalletClient, + encodeFunctionData, + http, + parseEther, + type Address, + type Hex, + type PublicClient, + type WalletClient, +} from "viem"; +import { beforeAll, describe, expect, it } from "vitest"; + +import type { ForkScenario, ForkSmokeConfig } from "@/index-dtf/fork-smoke-fixture"; +import type { IndexDtfCall } from "@/types/governance"; +import type { IndexDtf } from "@/types/index-dtf"; + +import { SUPPORTED_CHAINS } from "@/config"; +import { createDtfSdk, type DtfSdk } from "@/create-dtf-sdk"; +import { dtfAdminProposalAbi } from "@/index-dtf/abis/dtf-admin-proposal"; +import { dtfIndexAbi } from "@/index-dtf/abis/dtf-index-abi"; +import { DEFAULT_INDEX_DTF_DEPLOY_FLAGS, extractIndexDtfDeployedAddress } from "@/index-dtf/deploy"; +import { assertDisposableForkRpcUrl, readForkSmokeConfig } from "@/index-dtf/fork-smoke-fixture"; +import { + prepareIndexDtfAddToAllowlist, + prepareIndexDtfSetFeeRecipients, + prepareIndexDtfSetSelfFee, + prepareIndexDtfSetTradeAllowlistEnabled, +} from "@/index-dtf/governance/propose/calls"; +import { INDEX_DTF_START_REBALANCE_SELECTOR } from "@/index-dtf/governance/propose/upgrade-v6"; + +const runtimeEnv = + (globalThis as unknown as { process?: { env?: Record } }).process?.env ?? {}; +const forkDescribe = runtimeEnv.RUN_INDEX_DTF_FORK_MUTATING === "1" ? describe : describe.skip; +const forkSmokeTestTimeout = 120_000; +const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; +const DEFAULT_ADMIN_ROLE = "0x0000000000000000000000000000000000000000000000000000000000000000"; + +/** + * The writing v6 cases: SDK-built settings, deploy and upgrade executed by impersonated senders, each inside + * `evm_snapshot` / `evm_revert`, with a 400-day warp for the upgrade. They must never touch the sandbox's indexed Anvil + * (8545): rewinding a chain under its Graph Node corrupts the index. Run them on a disposable Anvil forked from the + * sandbox (`anvil --fork-url http://127.0.0.1:8545 --port `), passed as INDEX_DTF_FORK_DISPOSABLE_RPC_URL; the + * fixture manifest still names the scenarios. + */ +forkDescribe("Index DTF v6 completion fork smoke (mutating, disposable fork)", () => { + let config: ForkSmokeConfig; + let publicClient: PublicClient; + let wallet: WalletClient; + let sdk: DtfSdk; + + beforeAll(async () => { + const indexed = readForkSmokeConfig(runtimeEnv); + const disposableRpcUrl = runtimeEnv.INDEX_DTF_FORK_DISPOSABLE_RPC_URL; + if (!disposableRpcUrl) { + throw new Error("INDEX_DTF_FORK_DISPOSABLE_RPC_URL is required: an Anvil forked from the sandbox, never 8545"); + } + assertDisposableForkRpcUrl(disposableRpcUrl, { indexedRpcUrl: indexed.rpcUrl }); + config = { ...indexed, rpcUrl: disposableRpcUrl }; + const chain = SUPPORTED_CHAINS[config.chainId]; + publicClient = createPublicClient({ chain, transport: http(config.rpcUrl) }); + wallet = createWalletClient({ chain, transport: http(config.rpcUrl) }); + sdk = createDtfSdk({ chains: { [config.chainId]: { publicClient } } }); + + const clientVersion = await publicClient.request({ method: "web3_clientVersion" }); + if (!clientVersion.toLowerCase().startsWith("anvil")) { + throw new Error(`${config.rpcUrl} is not an Anvil fork (${clientVersion})`); + } + const forkChainId = await publicClient.getChainId(); + if (forkChainId !== config.chainId) { + throw new Error(`${config.rpcUrl} reports chain ${forkChainId}, the fixture is chain ${config.chainId}`); + } + // A fork of the sandbox carries the fixture: the v6 native Folio must already exist at its address. + const v6Native = config.scenarios.find((scenario) => scenario.label === "v6Native"); + if (!v6Native || !(await publicClient.getCode({ address: v6Native.folio }))) { + throw new Error(`${config.rpcUrl} does not carry the sandbox fixture; fork it from the sandbox RPC`); + } + }, forkSmokeTestTimeout); + + it( + "executes SDK-built v6 settings from the admin timelock and reads them back", + async () => { + const scenario = getScenario("v6Native"); + const timelock = scenario.governanceAddresses?.timelock; + const actor = scenario.execution?.actor; + const governance = scenario.governance; + if (!timelock || !actor || !governance) throw new Error("v6Native timelock, actor or governance is missing"); + const address = scenario.folio; + const chainId = config.chainId; + + await withSnapshot(async () => { + await impersonate(timelock); + await send(timelock, prepareIndexDtfSetSelfFee({ address, chainId, version: "6.0.0", percentage: 5 })); + await send( + timelock, + prepareIndexDtfSetFeeRecipients({ + address, + chainId, + version: "6.0.0", + recipients: [{ recipient: actor, portion: parseEther("0.6") }], + immutableRecipients: [{ recipient: timelock, portion: parseEther("0.4") }], + }), + ); + await send(timelock, prepareIndexDtfAddToAllowlist({ address, chainId, version: "6.0.0", tokens: [WETH] })); + await send( + timelock, + prepareIndexDtfSetTradeAllowlistEnabled({ address, chainId, version: "6.0.0", enabled: true }), + ); + + expect(await sdk.index.getSelfFee({ address, chainId })).toEqual({ + raw: parseEther("0.05"), + formatted: "0.05", + }); + expect(await sdk.index.getImmutableFeeRecipients({ address, chainId })).toEqual([ + { recipient: timelock, portion: parseEther("0.4") }, + ]); + expect(await sdk.index.getTradeAllowlist({ address, chainId })).toEqual({ enabled: true, tokens: [WETH] }); + expect(await sdk.index.getIsTokenAllowlisted({ address, chainId, token: WETH })).toBe(true); + + // A revenue proposal built on this state must preserve the immutable table and fit the rest. + const proposal = await sdk.index.buildSettingsProposal({ + address, + chainId, + version: "6.0.0", + governance, + timelock, + dtf: fakeDtfContext(actor, governance, timelock), + revenueDistribution: { platformFee: 0, governanceShare: 0, deployerShare: 100, additionalRecipients: [] }, + }); + for (const [index, data] of proposal.calldatas.entries()) { + await send(timelock, { chainId, to: proposal.targets[index]!, data, value: 0n }); + } + expect(await sdk.index.getImmutableFeeRecipients({ address, chainId })).toEqual([ + { recipient: timelock, portion: parseEther("0.4") }, + ]); + }); + }, + forkSmokeTestTimeout, + ); + + it( + "deploys a Folio 6.0 through the SDK against the sandbox's v6 deployer", + async () => { + const actor = getScenario("v6Native").execution?.actor; + if (!actor) throw new Error("actor is missing"); + const chainId = config.chainId; + const amount = parseEther("1"); + + await withSnapshot(async () => { + await impersonate(actor); + const plan = sdk.index.prepareDeployPlan({ + chainId, + version: "6.0.0", + deployer: config.protocol.v6Deployer, + basicDetails: { + name: "Fork smoke v6", + symbol: "fsV6", + assets: [WETH], + amounts: [amount], + initialShares: parseEther("1"), + }, + additionalDetails: { + maxAuctionLength: config.writePaths.auctionLength, + feeRecipients: [{ recipient: actor, portion: parseEther("0.9") }], + immutableFeeRecipients: [{ recipient: config.protocol.versionRegistry, portion: parseEther("0.1") }], + tvlFee: 0n, + mintFee: 0n, + selfFee: parseEther("0.02"), + mandate: "Fork smoke v6 deploy", + }, + flags: DEFAULT_INDEX_DTF_DEPLOY_FLAGS, + owner: actor, + approvals: [{ token: WETH, amount }], + }); + if (plan.type !== "approval-required") throw new Error("expected an approval plan"); + for (const approval of plan.approvals) await send(actor, approval); + const receipt = await send(actor, plan.call); + const folio = extractIndexDtfDeployedAddress(receipt.logs, { + chainId, + version: "6.0.0", + deployer: config.protocol.v6Deployer, + }); + + expect(await sdk.index.getVersion({ address: folio, chainId })).toBe("6.0.0"); + expect(await sdk.index.getMaxAuctionLength({ address: folio, chainId })).toBe(config.writePaths.auctionLength); + expect(await sdk.index.getSelfFee({ address: folio, chainId })).toEqual({ + raw: parseEther("0.02"), + formatted: "0.02", + }); + expect(await sdk.index.getImmutableFeeRecipients({ address: folio, chainId })).toEqual([ + { recipient: config.protocol.versionRegistry, portion: parseEther("0.1") }, + ]); + }); + }, + forkSmokeTestTimeout, + ); + + it( + "upgrades the v5 control folio to 6.0.0 with the SDK-built spell calls executed by a timelock admin", + async () => { + const scenario = getScenario("v5Control"); + const legacy = getScenario("v5LegacyUpgrade"); + const optimistic = getScenario("v5OptimisticUpgrade"); + const execution = scenario.execution; + const actor = execution?.actor; + const timelock = legacy.governanceAddresses?.timelock; + if (!execution || !actor || !timelock) throw new Error("v5Control execution or a legacy timelock is missing"); + if (!legacy.proposal || !optimistic.proposal || !optimistic.governanceAddresses) { + throw new Error("recorded upgrade proposals are missing"); + } + const chainId = config.chainId; + const folio = scenario.folio; + + // The SDK must rebuild both recorded upgrade proposals from the fixture's own addresses. + expect(config.protocol.startRebalanceSelectors).toEqual(INDEX_DTF_START_REBALANCE_SELECTOR); + expect( + sdk.index + .buildUpgradeToV6Calls({ + chainId, + address: legacy.folio, + proxyAdmin: legacy.proxyAdmin, + spell: config.protocol.upgradeSpell, + }) + .map((call) => call.data), + ).toEqual(legacy.proposal.calldatas); + expect( + sdk.index + .buildUpgradeToV6Calls({ + chainId, + address: optimistic.folio, + proxyAdmin: optimistic.proxyAdmin, + spell: config.protocol.upgradeSpell, + selectorRegistry: optimistic.governanceAddresses.selectorRegistry, + }) + .map((call) => call.data), + ).toEqual(optimistic.proposal.calldatas); + + await withSnapshot(async () => { + // The spell only accepts a contract admin (it enumerates the sender's proposer role), so hand the + // direct-admin control folio to the legacy scenario's timelock first, exactly as governance would hold it. + await impersonate(actor); + await impersonate(timelock); + await send(actor, { + chainId, + to: folio, + data: encodeFunctionData({ + abi: dtfIndexAbi, + functionName: "grantRole", + args: [DEFAULT_ADMIN_ROLE, timelock], + }), + value: 0n, + }); + await send(actor, { + chainId, + to: folio, + data: encodeFunctionData({ + abi: dtfIndexAbi, + functionName: "renounceRole", + args: [DEFAULT_ADMIN_ROLE, actor], + }), + value: 0n, + }); + await send(actor, { + chainId, + to: scenario.proxyAdmin, + data: encodeFunctionData({ abi: dtfAdminProposalAbi, functionName: "transferOwnership", args: [timelock] }), + value: 0n, + }); + + const calls = sdk.index.buildUpgradeToV6Calls({ + chainId, + address: folio, + proxyAdmin: scenario.proxyAdmin, + spell: config.protocol.upgradeSpell, + }); + expect(await sdk.index.getVersion({ address: folio, chainId })).toBe("5.0.0"); + // The spell refuses an upgrade while the fixture's rebalance window or its last auction is still open. + await publicClient.request({ method: "evm_increaseTime" as never, params: [400 * 86_400] as never }); + await publicClient.request({ method: "evm_mine" as never, params: [] as never }); + for (const call of calls) await send(timelock, call); + + expect(await sdk.index.getVersion({ address: folio, chainId })).toBe("6.0.0"); + expect(await sdk.index.getMaxAuctionLength({ address: folio, chainId })).toBe(BigInt(execution.auctionLength)); + expect(await sdk.index.getRebalanceNonce({ address: folio, chainId })).toBe(BigInt(execution.rebalanceNonce)); + expect(await sdk.index.getImmutableFeeRecipients({ address: folio, chainId })).toEqual([]); + }); + }, + forkSmokeTestTimeout, + ); + + function getScenario(label: ForkScenario["label"]): ForkScenario { + const scenario = config.scenarios.find((candidate) => candidate.label === label); + if (!scenario) throw new Error(`Missing scenario ${label}`); + return scenario; + } + + const impersonated = new Set
(); + + async function withSnapshot(run: () => Promise) { + const snapshot = (await publicClient.request({ method: "evm_snapshot" as never, params: [] as never })) as Hex; + try { + await run(); + } finally { + await publicClient.request({ method: "evm_revert" as never, params: [snapshot] as never }); + // Impersonation is node state, not chain state: a revert does not clear it. + for (const account of impersonated) { + await publicClient.request({ method: "anvil_stopImpersonatingAccount" as never, params: [account] as never }); + } + impersonated.clear(); + } + } + + async function impersonate(account: Address) { + impersonated.add(account); + await publicClient.request({ method: "anvil_impersonateAccount" as never, params: [account] as never }); + await publicClient.request({ + method: "anvil_setBalance" as never, + params: [account, `0x${parseEther("10").toString(16)}`] as never, + }); + } + + async function send(from: Address, call: Pick) { + const hash = await wallet.sendTransaction({ + account: from, + chain: SUPPORTED_CHAINS[config.chainId], + to: call.to, + data: call.data, + value: call.value, + }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (receipt.status !== "success") throw new Error(`transaction reverted: ${hash}`); + return receipt; + } + + function fakeDtfContext(deployer: Address, governance: Address, timelock: Address): IndexDtf { + return { + governance: { + admin: { + primary: { + type: "governance", + address: governance, + governance: { address: governance, quorumDenominator: 100, timelock: { address: timelock, guardians: [] } }, + }, + }, + }, + roles: { metadata: { brandManagers: [] }, rebalance: { auctionLaunchers: [] }, deployment: { deployer } }, + rebalance: { weightControl: true, priceControl: 1 }, + fees: { recipients: [] }, + } as unknown as IndexDtf; + } +}); diff --git a/packages/sdk/src/index-dtf/fork-smoke-v6.test.ts b/packages/sdk/src/index-dtf/fork-smoke-v6.test.ts new file mode 100644 index 0000000..1f589fc --- /dev/null +++ b/packages/sdk/src/index-dtf/fork-smoke-v6.test.ts @@ -0,0 +1,85 @@ +import { createPublicClient, http, type PublicClient } from "viem"; +import { beforeAll, describe, expect, it } from "vitest"; + +import type { ForkScenario, ForkSmokeConfig } from "@/index-dtf/fork-smoke-fixture"; + +import { SUPPORTED_CHAINS } from "@/config"; +import { createDtfSdk, type DtfSdk } from "@/create-dtf-sdk"; +import { readForkSmokeConfig } from "@/index-dtf/fork-smoke-fixture"; + +const runtimeEnv = + (globalThis as unknown as { process?: { env?: Record } }).process?.env ?? {}; +const forkDescribe = runtimeEnv.RUN_INDEX_DTF_FORK_SMOKE === "1" ? describe : describe.skip; +const forkSmokeTestTimeout = 120_000; +const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; + +/** + * Read-only v6 checks against the sandbox's indexed Anvil (8545): no transactions, snapshots, reverts or time warps, + * so it is safe while a fork Graph Node indexes the chain. The writing v6 cases live in + * `fork-smoke-v6-mutating.test.ts` and run on a disposable fork. + */ +forkDescribe("Index DTF v6 completion fork smoke (read-only)", () => { + let config: ForkSmokeConfig; + let sdk: DtfSdk; + + beforeAll(() => { + config = readForkSmokeConfig(runtimeEnv); + const publicClient: PublicClient = createPublicClient({ + chain: SUPPORTED_CHAINS[config.chainId], + transport: http(config.rpcUrl), + }); + sdk = createDtfSdk({ chains: { [config.chainId]: { publicClient } } }); + }); + + it( + "reads the sandbox's version registry: 6.0.0 registered with the fixture's deployer and implementation", + async () => { + const registry = config.protocol.versionRegistry; + const at = { chainId: config.chainId, registry, blockNumber: config.stateBlock } as const; + const latest = await sdk.index.getLatestVersion(at); + const v6 = await sdk.index.getVersionDeployment({ ...at, version: "6.0.0" }); + const v5 = await sdk.index.getVersionDeployment({ ...at, version: "5.0.0" }); + + expect(latest).toMatchObject({ version: "6.0.0", deployer: config.protocol.v6Deployer, deprecated: false }); + expect(v6).toMatchObject({ + deployer: config.protocol.v6Deployer, + implementation: config.protocol.v6Implementation, + }); + expect(v5?.deployer).toBe(config.protocol.v5Deployer); + await expect(sdk.index.getVersionDeployment({ ...at, version: "9.9.9" })).resolves.toBeNull(); + }, + forkSmokeTestTimeout, + ); + + it( + "reads native v6 state through the SDK and rejects the immutable-table read on a v5 folio", + async () => { + const v6 = getScenario("v6Native"); + const v5 = getScenario("v5Control"); + if (!v6.execution) throw new Error("v6Native execution evidence is missing"); + // Pinned to the fixture's state block: the indexed lane appends rebalances after the fixture is recorded. + const params = { address: v6.folio, chainId: config.chainId, blockNumber: config.stateBlock } as const; + + expect(await sdk.index.getMaxAuctionLength(params)).toBe(config.writePaths.auctionLength); + expect(await sdk.index.getRebalanceNonce(params)).toBe(BigInt(v6.execution.rebalanceNonce)); + expect(await sdk.index.getSelfFee(params)).toEqual({ raw: 0n, formatted: "0" }); + expect(await sdk.index.getImmutableFeeRecipients(params)).toEqual([]); + expect(await sdk.index.getTradeAllowlist(params)).toEqual({ enabled: false, tokens: [] }); + expect(await sdk.index.getIsTokenAllowlisted({ ...params, token: WETH })).toBe(false); + await expect( + sdk.index.getImmutableFeeRecipients({ + address: v5.folio, + chainId: config.chainId, + blockNumber: config.stateBlock, + }), + ).rejects.toThrow(); + }, + forkSmokeTestTimeout, + ); + + function getScenario(label: ForkScenario["label"]): ForkScenario { + const scenario = config.scenarios.find((candidate) => candidate.label === label); + if (!scenario) throw new Error(`Missing scenario ${label}`); + return scenario; + } +}); diff --git a/packages/sdk/src/index-dtf/fork-smoke.test.ts b/packages/sdk/src/index-dtf/fork-smoke.test.ts new file mode 100644 index 0000000..487c772 --- /dev/null +++ b/packages/sdk/src/index-dtf/fork-smoke.test.ts @@ -0,0 +1,811 @@ +import { + createPublicClient, + decodeFunctionData, + erc20Abi, + http, + keccak256, + parseEventLogs, + parseEther, + toBytes, + type Abi, + type Address, + type Hash, + type Hex, + type PublicClient, +} from "viem"; +import { beforeAll, describe, expect, it } from "vitest"; + +import type { + ForkExecutedGovernanceEvidence, + ForkExecutionEvidence, + ForkScenario, + ForkSmokeConfig, +} from "@/index-dtf/fork-smoke-fixture"; +import type { OpenAuctionArgs } from "@/index-dtf/rebalance/types"; + +import { SUPPORTED_CHAINS } from "@/config"; +import { createDtfSdk, type DtfSdk } from "@/create-dtf-sdk"; +import { indexDtfDeployerAbi } from "@/index-dtf/abis/deployer"; +import { dtfIndexAbi } from "@/index-dtf/abis/dtf-index-abi"; +import { dtfIndexGovernanceAbi } from "@/index-dtf/abis/dtf-index-governance"; +import { dtfIndexGovernanceOptimisticAbi } from "@/index-dtf/abis/dtf-index-governance-optimistic"; +import { folioArtifactAbi } from "@/index-dtf/abis/folio-artifact"; +import { DEFAULT_INDEX_DTF_DEPLOY_FLAGS } from "@/index-dtf/deploy"; +import { readForkSmokeConfig } from "@/index-dtf/fork-smoke-fixture"; +import { + getIndexDtfWriteAbi, + prepareIndexDtfSetMandate, + prepareIndexDtfSetName, +} from "@/index-dtf/governance/propose/calls"; +import { governorWriteAbi } from "@/lib/governor-calls"; + +const runtimeEnv = + (globalThis as unknown as { process?: { env?: Record } }).process?.env ?? {}; +const runForkSmoke = runtimeEnv.RUN_INDEX_DTF_FORK_SMOKE === "1"; +const forkDescribe = runForkSmoke ? describe : describe.skip; +// Archive-backed Anvil reads can need several seconds on a cold cache. Keep +// this suite deterministic and low-pressure instead of flooding the fork with +// concurrent historical calls under Vitest's five-second unit-test default. +const forkSmokeTestTimeout = 60_000; +const OPTIMISTIC_PROPOSER_ROLE = keccak256(toBytes("OPTIMISTIC_PROPOSER_ROLE")); +const AUCTION_LAUNCHER_ROLE = keccak256(toBytes("AUCTION_LAUNCHER")); +const LIVE_REBALANCE_SECOND_TOKEN = { + 1: "0x6B175474E89094C44Da98b954EedeAC495271d0F", + 8453: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + 56: "0x55d398326f99059fF775485246999027B3197955", +} as const satisfies Record; + +type FixtureState = { + readonly totalSupply: bigint; + readonly tokens: readonly Address[]; + readonly balances: readonly bigint[]; + readonly mandate: string; + readonly decimals: readonly number[]; + readonly auctionLength: bigint; +}; + +forkDescribe("Index DTF deterministic fork smoke", () => { + let config: ForkSmokeConfig; + let publicClient: PublicClient; + let sdk: DtfSdk; + const stateByFolio = new Map>(); + const decimalsByToken = new Map>(); + + beforeAll(() => { + config = readForkSmokeConfig(runtimeEnv); + publicClient = createPublicClient({ + chain: SUPPORTED_CHAINS[config.chainId], + transport: http(config.rpcUrl), + }); + sdk = createDtfSdk({ chains: { [config.chainId]: { publicClient } } }); + }); + + it( + "uses only the configured local fork and reaches the pinned state block", + async () => { + const [rpcChainId, latestBlock, stateBlock] = await Promise.all([ + publicClient.getChainId(), + publicClient.getBlockNumber(), + publicClient.getBlock({ blockNumber: config.stateBlock }), + ]); + + expect(rpcChainId).toBeGreaterThan(0); + expect(latestBlock).toBeGreaterThanOrEqual(config.stateBlock); + expect(stateBlock.timestamp).toBe(config.stateTimestamp); + }, + forkSmokeTestTimeout, + ); + + it( + "proves all four scenario creations at their declared blocks", + async () => { + for (const scenario of config.scenarios) { + await expectCreationAtBlock(publicClient, scenario.folio, scenario.creationBlock); + } + }, + forkSmokeTestTimeout, + ); + + it( + "detects v5 before and v6 after both declared upgrade paths", + async () => { + const upgraded = config.scenarios.filter( + (scenario): scenario is ForkScenario & { readonly upgradeBlock: bigint } => scenario.upgradeBlock !== undefined, + ); + + for (const scenario of upgraded) { + await expect( + sdk.index.getVersion({ + address: scenario.folio, + chainId: config.chainId, + blockNumber: scenario.creationBlock, + }), + scenario.label, + ).resolves.toBe("5.0.0"); + await expect( + sdk.index.getVersion({ + address: scenario.folio, + chainId: config.chainId, + blockNumber: scenario.upgradeBlock, + }), + scenario.label, + ).resolves.toBe("6.0.0"); + await expectUpgradeProposalEvidence(publicClient, scenario, config.stateBlock); + } + }, + forkSmokeTestTimeout, + ); + + it( + "detects every current version and caches pinned Folio data reads", + async () => { + const states: FixtureState[] = []; + for (const scenario of config.scenarios) states.push(await getFixtureState(scenario)); + + for (let index = 0; index < config.scenarios.length; index++) { + const scenario = config.scenarios[index]!; + const state = states[index]!; + const version = await sdk.index.getVersion({ + address: scenario.folio, + chainId: config.chainId, + blockNumber: config.stateBlock, + }); + + expect(version, scenario.label).toBe(scenario.expectedVersion); + expect(state.totalSupply, scenario.label).toBeGreaterThan(0n); + expect(state.tokens.length, scenario.label).toBeGreaterThanOrEqual(1); + expect(state.balances, scenario.label).toHaveLength(state.tokens.length); + expect(state.decimals, scenario.label).toHaveLength(state.tokens.length); + expect(typeof state.mandate, scenario.label).toBe("string"); + } + }, + forkSmokeTestTimeout, + ); + + it( + "reads back linked active v5 direct and v6 optimistic executions", + async () => { + const v5 = getScenarioWithExecution("v5Control"); + const v6 = getScenarioWithExecution("v6Native"); + + expect(v5.governance).toBeUndefined(); + expect(v5.execution.roleAuthority).toBe("direct-admin"); + expect(v6.governanceKind).toBe("optimistic"); + expect(v6.execution.roleAuthority).toBe("optimistic-governance"); + if (v6.execution.roleAuthority !== "optimistic-governance" || !v6.governance || !v6.governanceAddresses) { + throw new Error("v6Native must contain optimistic execution evidence"); + } + if (v6.execution.authority) { + expect(v6.execution.authority).toMatchObject({ kind: "standard", optimistic: false }); + } + expect(v6.execution.rebalanceProposal).toMatchObject({ kind: "optimistic", optimistic: true }); + + for (const scenario of [v5, v6]) { + const params = { + address: scenario.folio, + chainId: config.chainId, + blockNumber: config.stateBlock, + } as const; + const [current, latestAuction, activeAuction] = await Promise.all([ + sdk.index.getCurrentRebalance(params), + sdk.index.getLatestAuction(params), + sdk.index.getActiveAuction(params), + ]); + const expectedTokens = normalizeAddresses(scenario.execution.tokens); + + expect(current.rebalance.nonce, `${scenario.label} rebalance nonce`).toBe(1n); + expect( + normalizeAddresses(current.rebalance.tokens.map(({ token }) => token)), + `${scenario.label} tokens`, + ).toEqual(expectedTokens); + expect(current.rebalance.timestamps.startedAt, `${scenario.label} rebalance started`).toBeLessThanOrEqual( + config.stateTimestamp, + ); + expect(current.rebalance.timestamps.availableUntil, `${scenario.label} rebalance active`).toBeGreaterThan( + config.stateTimestamp, + ); + expect(latestAuction, `${scenario.label} latest auction`).toMatchObject({ auctionId: 0n, rebalanceNonce: 1n }); + if (!latestAuction) throw new Error(`${scenario.label} latest auction is missing`); + expect(latestAuction.endTime - latestAuction.startTime, `${scenario.label} auction length`).toBe(300n); + if (scenario.label === "v5Control") { + // Folio opens auctions with a warm-up, and how long the sandbox's later stages took decides + // whether the window is still open at the pinned state, so the reader's verdict is checked + // against the on-chain window at every fixture block from the launch to the state block. + let liveBlocks = 0; + for (let block = scenario.execution.auctionBlock; block <= config.stateBlock; block++) { + const { timestamp } = await publicClient.getBlock({ blockNumber: block }); + const live = latestAuction.startTime <= timestamp && timestamp <= latestAuction.endTime; + const [latestAt, activeAt] = await Promise.all([ + sdk.index.getLatestAuction({ address: scenario.folio, chainId: config.chainId, blockNumber: block }), + sdk.index.getActiveAuction({ address: scenario.folio, chainId: config.chainId, blockNumber: block }), + ]); + expect(latestAt?.isActive, `${scenario.label} auction active at block ${block}`).toBe(live); + expect(activeAt === null, `${scenario.label} active auction at block ${block}`).toBe(!live); + if (live) liveBlocks++; + } + expect(latestAuction.startTime, `${scenario.label} auction warm-up`).toBeGreaterThan( + (await publicClient.getBlock({ blockNumber: scenario.execution.auctionBlock })).timestamp, + ); + expect(liveBlocks, `${scenario.label} live blocks`).toBeGreaterThan(0); + } else { + // The two fixture windows intentionally do not overlap: at the pinned + // state the v5 auction is active while v6 is opened but starts later. + expect(latestAuction.startTime, `${scenario.label} scheduled auction start`).toBeGreaterThan( + config.stateTimestamp, + ); + expect(latestAuction.isActive, `${scenario.label} scheduled auction`).toBe(false); + expect(activeAuction, `${scenario.label} active auction before start`).toBeNull(); + } + + const folioAbi = scenario.expectedVersion === "6.0.0" ? folioArtifactAbi : dtfIndexAbi; + await expectFolioExecutionReceipt({ + publicClient, + folio: scenario.folio, + actor: scenario.execution.actor, + transactionHash: scenario.execution.rebalanceTxHash, + blockNumber: scenario.execution.rebalanceBlock, + transactionTarget: + scenario.execution.roleAuthority === "optimistic-governance" ? scenario.governance : scenario.folio, + abi: folioAbi, + eventName: "RebalanceStarted", + nonce: scenario.execution.rebalanceNonce, + tokens: scenario.execution.tokens, + }); + const auctionEvent = await expectFolioExecutionReceipt({ + publicClient, + folio: scenario.folio, + actor: scenario.execution.actor, + transactionHash: scenario.execution.auctionTxHash, + blockNumber: scenario.execution.auctionBlock, + transactionTarget: scenario.folio, + abi: folioAbi, + eventName: "AuctionOpened", + nonce: scenario.execution.rebalanceNonce, + auctionId: scenario.execution.auctionId, + tokens: scenario.execution.tokens, + }); + expect(latestAuction.startTime, `${scenario.label} auction start event/readback`).toBe(auctionEvent.startTime); + expect(latestAuction.endTime, `${scenario.label} auction end event/readback`).toBe(auctionEvent.endTime); + } + + // The sandbox skips the authority proposal when the roles were already granted. + if (v6.execution.authority) { + await expectGovernanceExecutionEvidence({ + publicClient, + governance: v6.governance, + actor: v6.execution.actor, + evidence: v6.execution.authority, + optimistic: false, + stateBlock: config.stateBlock, + action: "authority", + folio: v6.folio, + timelock: v6.governanceAddresses.timelock, + }); + } + await expectGovernanceExecutionEvidence({ + publicClient, + governance: v6.governance, + actor: v6.execution.actor, + evidence: v6.execution.rebalanceProposal, + optimistic: true, + stateBlock: config.stateBlock, + action: "rebalance", + folio: v6.folio, + tokens: v6.execution.tokens, + nonce: v6.execution.rebalanceNonce, + deadline: v6.execution.deadline, + }); + expect(v6.execution.rebalanceProposal.executeTxHash).toBe(v6.execution.rebalanceTxHash); + expect(v6.execution.rebalanceProposal.executeBlock).toBe(v6.execution.rebalanceBlock); + expect(v6.execution.deadline).toBe(config.writePaths.deadline); + }, + forkSmokeTestTimeout, + ); + + it( + "preserves Governor call order and encodes the live v6 nonce and deadline", + async () => { + const standardScenarios = config.scenarios.filter( + (scenario): scenario is ForkScenario & { readonly governance: Address } => scenario.governance !== undefined, + ); + + for (const scenario of standardScenarios) { + const callCount = scenario.proposalCallCount ?? 2; + const calls = Array.from({ length: callCount }, (_, index) => + index % 2 === 0 + ? prepareIndexDtfSetMandate({ + address: scenario.folio, + chainId: config.chainId, + version: scenario.expectedVersion, + mandate: `Fork smoke ${scenario.label} call ${index}`, + }) + : prepareIndexDtfSetName({ + address: scenario.folio, + chainId: config.chainId, + version: scenario.expectedVersion, + name: `Fork smoke ${scenario.label} call ${index}`, + }), + ); + const targets = calls.map(() => scenario.folio); + const calldatas = calls.map((call) => call.data); + const proposal = sdk.index.prepareSubmitProposal({ + chainId: config.chainId, + proposal: { + governance: scenario.governance, + targets, + calldatas, + description: `Fork smoke proposal: ${scenario.label}`, + }, + }); + const decodedProposal = decodeFunctionData({ abi: governorWriteAbi, data: proposal.data }); + const writeAbi = getIndexDtfWriteAbi(scenario.expectedVersion); + const decodedCalls = calls.map((call) => decodeFunctionData({ abi: writeAbi, data: call.data })); + + expect(proposal.to, scenario.label).toBe(scenario.governance); + expect(decodedProposal.functionName, scenario.label).toBe("propose"); + expect(decodedProposal.args, scenario.label).toEqual([ + targets, + calls.map(() => 0n), + calldatas, + `Fork smoke proposal: ${scenario.label}`, + ]); + expect( + decodedCalls.map((call) => call.functionName), + scenario.label, + ).toEqual(Array.from({ length: callCount }, (_, index) => (index % 2 === 0 ? "setMandate" : "setName"))); + } + + const scenario = getScenario("v6Native"); + if (!scenario.governance) throw new Error("v6Native governance is missing"); + const state = await getFixtureState(scenario); + const secondToken = LIVE_REBALANCE_SECOND_TOKEN[config.chainId]; + const tokens = [...state.tokens, ...(state.tokens.includes(secondToken) ? [] : [secondToken])]; + if (tokens.length < 2) throw new Error("fork v6 rebalance smoke requires two distinct tokens"); + const currentBalances = Object.fromEntries( + state.tokens.map((token, index) => [token, state.balances[index]!]), + ) as Record; + currentBalances[secondToken] ??= 0n; + const prices = Object.fromEntries(tokens.map((token) => [token, 1])) as Record; + const priceErrors = Object.fromEntries(tokens.map((token) => [token, 0.5])) as Record; + const currentNonce = await publicClient.readContract({ + address: scenario.folio, + abi: folioArtifactAbi, + functionName: "getRebalanceNonce", + blockNumber: config.stateBlock, + }); + const basketProposal = await sdk.index.buildBasketProposal({ + address: scenario.folio, + chainId: config.chainId, + blockNumber: config.stateBlock, + version: "6.0.0", + governance: scenario.governance, + supply: state.totalSupply, + currentBalances, + prices, + priceErrors, + weightControl: true, + basket: { type: "units", tokens: tokens.map((address) => ({ address, units: "1" })) }, + auctionLauncherWindow: 3600, + ttl: 10_800, + deadline: config.writePaths.deadline, + }); + const decodedRebalance = decodeFunctionData({ abi: folioArtifactAbi, data: basketProposal.calldatas[0]! }); + + expect(decodedRebalance.functionName).toBe("startRebalance"); + expect(basketProposal.targets).toEqual([scenario.folio]); + expect(basketProposal.calldatas).toHaveLength(1); + expect(decodedRebalance.args).toEqual([ + currentNonce + 1n, + basketProposal.context.startRebalanceArgs.tokens, + basketProposal.context.startRebalanceArgs.limits, + 3600n, + 10_800n, + config.writePaths.deadline, + ]); + }, + forkSmokeTestTimeout, + ); + + it( + "encodes v5 and v6 openAuction with version-specific auctionLength ordering", + async () => { + const v5 = getScenario("v5Control"); + const v6 = getScenario("v6Native"); + const v5State = await getFixtureState(v5); + const v6State = await getFixtureState(v6); + const v5Call = sdk.index.prepareOpenAuction({ + address: v5.folio, + chainId: config.chainId, + version: "5.0.0", + args: getOpenAuctionArgs(v5State), + }); + const v6Call = sdk.index.prepareOpenAuction({ + address: v6.folio, + chainId: config.chainId, + version: "6.0.0", + args: getOpenAuctionArgs(v6State), + auctionLength: config.writePaths.auctionLength, + }); + const v5Decoded = decodeFunctionData({ abi: dtfIndexAbi, data: v5Call.data }); + const v6Decoded = decodeFunctionData({ abi: folioArtifactAbi, data: v6Call.data }); + + expect(v5Decoded.functionName).toBe("openAuction"); + expect(v5Decoded.args).toHaveLength(5); + expect(v6Decoded.functionName).toBe("openAuction"); + expect(v6Decoded.args).toHaveLength(6); + expect(config.writePaths.auctionLength).toBe(v6State.auctionLength); + expect(v6Decoded.args[5]).toBe(config.writePaths.auctionLength); + }, + forkSmokeTestTimeout, + ); + + it( + "encodes the existing v5 deploy builder against fixture assets", + async () => { + const scenario = getScenario("v5Control"); + const governedScenario = getScenario("v5LegacyUpgrade"); + if (!governedScenario.governance) throw new Error("v5LegacyUpgrade governance is missing"); + const state = await getFixtureState(scenario); + const call = sdk.index.prepareDeploy({ + chainId: config.chainId, + version: "5.0.0", + basicDetails: { + name: "Fork smoke deploy", + symbol: "fsDTF", + assets: state.tokens, + amounts: state.tokens.map(() => 1n), + initialShares: parseEther("1"), + }, + additionalDetails: { + auctionLength: state.auctionLength, + feeRecipients: [{ recipient: governedScenario.governance, portion: parseEther("1") }], + tvlFee: 0n, + mintFee: 0n, + mandate: "Fork smoke deploy", + }, + flags: DEFAULT_INDEX_DTF_DEPLOY_FLAGS, + owner: governedScenario.governance, + deploymentNonce: "0x0000000000000000000000000000000000000000000000000000000000000042", + }); + const decoded = decodeFunctionData({ abi: indexDtfDeployerAbi, data: call.data }); + const basicDetails = decoded.args[0] as { + readonly assets: readonly Address[]; + readonly amounts: readonly bigint[]; + }; + const additionalDetails = decoded.args[1] as { readonly auctionLength: bigint }; + + expect(decoded.functionName).toBe("deployFolio"); + expect(basicDetails.assets).toEqual(state.tokens); + expect(basicDetails.amounts).toEqual(state.tokens.map(() => 1n)); + expect(additionalDetails.auctionLength).toBe(state.auctionLength); + }, + forkSmokeTestTimeout, + ); + + function getScenario(label: ForkScenario["label"]): ForkScenario { + const scenario = config.scenarios.find((candidate) => candidate.label === label); + if (!scenario) throw new Error(`fixture scenario ${label} is missing`); + + return scenario; + } + + function getScenarioWithExecution( + label: "v5Control" | "v6Native", + ): ForkScenario & { readonly execution: ForkExecutionEvidence } { + const scenario = getScenario(label); + if (!scenario.execution) throw new Error(`fixture scenario ${label} is missing execution evidence`); + + return { ...scenario, execution: scenario.execution }; + } + + function getFixtureState(scenario: ForkScenario): Promise { + const cached = stateByFolio.get(scenario.folio); + if (cached) return cached; + + const state = loadFixtureState(scenario); + stateByFolio.set(scenario.folio, state); + return state; + } + + async function loadFixtureState(scenario: ForkScenario): Promise { + const params = { + address: scenario.folio, + chainId: config.chainId, + blockNumber: config.stateBlock, + } as const; + const totalSupply = await sdk.index.getTotalSupply(params); + const totalAssets = await sdk.index.getTotalAssets(params); + const mandate = await sdk.index.getMandate(params); + const auctionLength = await getAuctionLength(scenario); + const decimals: number[] = []; + for (const token of totalAssets.tokens) decimals.push(await getTokenDecimals(token)); + + return { + totalSupply, + tokens: totalAssets.tokens, + balances: totalAssets.balances, + mandate, + decimals, + auctionLength, + }; + } + + function getTokenDecimals(address: Address): Promise { + const cached = decimalsByToken.get(address); + if (cached) return cached; + + const decimals = publicClient.readContract({ + address, + abi: erc20Abi, + functionName: "decimals", + blockNumber: config.stateBlock, + }); + decimalsByToken.set(address, decimals); + return decimals; + } + + async function getAuctionLength(scenario: ForkScenario): Promise { + if (scenario.expectedVersion === "6.0.0") { + return publicClient.readContract({ + address: scenario.folio, + abi: folioArtifactAbi, + functionName: "maxAuctionLength", + blockNumber: config.stateBlock, + }); + } + + return publicClient.readContract({ + address: scenario.folio, + abi: dtfIndexAbi, + functionName: "auctionLength", + blockNumber: config.stateBlock, + }); + } +}); + +async function expectCreationAtBlock(publicClient: PublicClient, address: Address, creationBlock: bigint) { + const before = await publicClient.getCode({ address, blockNumber: previousBlock(creationBlock) }); + const after = await publicClient.getCode({ address, blockNumber: creationBlock }); + + expect(before).toBeUndefined(); + expect(after).toBeDefined(); + expect(after).not.toBe("0x"); +} + +async function expectUpgradeProposalEvidence(publicClient: PublicClient, scenario: ForkScenario, stateBlock: bigint) { + if (!scenario.governance || !scenario.proposal) { + throw new Error(`${scenario.label} is missing governance proposal evidence`); + } + + const { proposal } = scenario; + const [proposalState, creationLogs, executionLogs] = await Promise.all([ + publicClient.readContract({ + address: scenario.governance, + abi: dtfIndexGovernanceAbi, + functionName: "state", + args: [proposal.proposalId], + blockNumber: stateBlock, + }), + publicClient.getLogs({ + address: scenario.governance, + fromBlock: proposal.proposalBlock, + toBlock: proposal.proposalBlock, + }), + publicClient.getLogs({ + address: scenario.governance, + fromBlock: proposal.executeBlock, + toBlock: proposal.executeBlock, + }), + ]); + const created = parseEventLogs({ abi: dtfIndexGovernanceAbi, logs: creationLogs, strict: false }).find( + (log) => log.eventName === "ProposalCreated" && log.args.proposalId === proposal.proposalId, + ); + const executed = parseEventLogs({ abi: dtfIndexGovernanceAbi, logs: executionLogs, strict: false }).find( + (log) => log.eventName === "ProposalExecuted" && log.args.proposalId === proposal.proposalId, + ); + + expect(proposalState, `${scenario.label} proposal state`).toBe(7); + expect(created, `${scenario.label} ProposalCreated`).toBeDefined(); + if (!created || created.eventName !== "ProposalCreated") return; + const { targets, values, calldatas } = created.args; + if (!targets || !values || !calldatas) { + throw new Error(`${scenario.label} ProposalCreated log is missing call data`); + } + expect( + targets.map((target) => target.toLowerCase()), + `${scenario.label} proposal targets`, + ).toEqual(proposal.targets.map((target) => target.toLowerCase())); + expect(values, `${scenario.label} proposal values`).toEqual(proposal.values); + expect(calldatas, `${scenario.label} proposal calldatas`).toEqual(proposal.calldatas); + expect(executed, `${scenario.label} ProposalExecuted`).toBeDefined(); +} + +function getOpenAuctionArgs(state: FixtureState): OpenAuctionArgs { + const tokens = [...state.tokens]; + if (tokens.length === 0) throw new Error("fork write smoke requires a basket asset"); + + return { + rebalanceNonce: 1n, + tokens, + newWeights: tokens.map(() => ({ low: 0n, spot: parseEther("0.5"), high: parseEther("1") })), + newPrices: tokens.map(() => ({ low: parseEther("0.9"), high: parseEther("1.1") })), + newLimits: { low: parseEther("0.9"), spot: parseEther("1"), high: parseEther("1.1") }, + }; +} + +function previousBlock(blockNumber: bigint): bigint { + if (blockNumber === 0n) throw new Error("fixture transition block must be greater than zero"); + + return blockNumber - 1n; +} + +async function expectFolioExecutionReceipt(params: { + readonly publicClient: PublicClient; + readonly folio: Address; + readonly actor: Address; + readonly transactionHash: Hash; + readonly blockNumber: bigint; + readonly transactionTarget: Address | undefined; + readonly abi: Abi; + readonly eventName: "RebalanceStarted" | "AuctionOpened"; + readonly nonce: bigint; + readonly auctionId?: bigint; + readonly tokens: readonly Address[]; +}) { + if (!params.transactionTarget) throw new Error(`${params.eventName} transaction target is missing`); + const receipt = await params.publicClient.getTransactionReceipt({ hash: params.transactionHash }); + + expect(receipt.status, `${params.eventName} receipt status`).toBe("success"); + expect(receipt.transactionHash, `${params.eventName} transaction hash`).toBe(params.transactionHash); + expect(receipt.blockNumber, `${params.eventName} receipt block`).toBe(params.blockNumber); + expect(receipt.from.toLowerCase(), `${params.eventName} actor`).toBe(params.actor.toLowerCase()); + expect(receipt.to?.toLowerCase(), `${params.eventName} transaction target`).toBe( + params.transactionTarget.toLowerCase(), + ); + + const event = parseEventLogs({ + abi: params.abi, + logs: receipt.logs.filter(({ address }) => address.toLowerCase() === params.folio.toLowerCase()), + strict: false, + }).find((log) => log.eventName === params.eventName) as + | { + readonly args: { + readonly nonce?: bigint; + readonly rebalanceNonce?: bigint; + readonly auctionId?: bigint; + readonly tokens?: readonly (Address | { readonly token: Address })[]; + readonly startTime?: bigint; + readonly endTime?: bigint; + }; + } + | undefined; + expect(event, `${params.eventName} Folio event`).toBeDefined(); + if (!event?.args.tokens) throw new Error(`${params.eventName} event tokens are missing`); + const eventTokens = event.args.tokens.map((token) => (typeof token === "string" ? token : token.token)); + + expect(event.args.nonce ?? event.args.rebalanceNonce, `${params.eventName} nonce`).toBe(params.nonce); + if (params.eventName === "AuctionOpened") { + expect(event.args.auctionId, `${params.eventName} auction id`).toBe(params.auctionId); + } + expect(normalizeAddresses(eventTokens), `${params.eventName} tokens`).toEqual(normalizeAddresses(params.tokens)); + + return { startTime: event.args.startTime, endTime: event.args.endTime }; +} + +type GovernanceExecutionExpectation = { + readonly publicClient: PublicClient; + readonly governance: Address; + readonly actor: Address; + readonly evidence: ForkExecutedGovernanceEvidence; + readonly optimistic: boolean; + readonly stateBlock: bigint; + readonly folio: Address; +} & ( + | { readonly action: "authority"; readonly timelock: Address } + | { + readonly action: "rebalance"; + readonly tokens: readonly Address[]; + readonly nonce: bigint; + readonly deadline: bigint; + } +); + +async function expectGovernanceExecutionEvidence(params: GovernanceExecutionExpectation) { + const { publicClient, governance, actor, evidence, optimistic, stateBlock } = params; + const [proposalReceipt, executeReceipt, state, isOptimistic] = await Promise.all([ + publicClient.getTransactionReceipt({ hash: evidence.proposalTxHash }), + publicClient.getTransactionReceipt({ hash: evidence.executeTxHash }), + publicClient.readContract({ + address: governance, + abi: dtfIndexGovernanceAbi, + functionName: "state", + args: [evidence.proposalId], + blockNumber: stateBlock, + }), + publicClient.readContract({ + address: governance, + abi: dtfIndexGovernanceOptimisticAbi, + functionName: "isOptimistic", + args: [evidence.proposalId], + blockNumber: stateBlock, + }), + ]); + + for (const [receipt, hash, block, eventName] of [ + [proposalReceipt, evidence.proposalTxHash, evidence.proposalBlock, "ProposalCreated"], + [executeReceipt, evidence.executeTxHash, evidence.executeBlock, "ProposalExecuted"], + ] as const) { + expect(receipt.status, `${eventName} receipt status`).toBe("success"); + expect(receipt.transactionHash, `${eventName} transaction hash`).toBe(hash); + expect(receipt.blockNumber, `${eventName} receipt block`).toBe(block); + expect(receipt.from.toLowerCase(), `${eventName} actor`).toBe(actor.toLowerCase()); + expect(receipt.to?.toLowerCase(), `${eventName} governance target`).toBe(governance.toLowerCase()); + const matchingEvent = parseEventLogs({ abi: dtfIndexGovernanceAbi, logs: receipt.logs, strict: false }).find( + (log) => log.eventName === eventName && log.args.proposalId === evidence.proposalId, + ); + expect(matchingEvent, `${eventName} proposal linkage`).toBeDefined(); + } + + const created = parseEventLogs({ abi: dtfIndexGovernanceAbi, logs: proposalReceipt.logs, strict: false }).find( + (log) => log.eventName === "ProposalCreated" && log.args.proposalId === evidence.proposalId, + ); + if (!created || created.eventName !== "ProposalCreated") { + throw new Error(`ProposalCreated ${evidence.proposalId} is missing`); + } + const createdArgs = created.args as { + readonly proposer?: Address; + readonly targets?: readonly Address[]; + readonly values?: readonly bigint[]; + readonly signatures?: readonly string[]; + readonly calldatas?: readonly Hex[]; + readonly description?: string; + }; + if ( + !createdArgs.proposer || + !createdArgs.targets || + !createdArgs.values || + !createdArgs.signatures || + !createdArgs.calldatas || + createdArgs.description === undefined + ) { + throw new Error(`ProposalCreated ${evidence.proposalId} payload is incomplete`); + } + expect(createdArgs.proposer.toLowerCase(), "proposal proposer").toBe(actor.toLowerCase()); + expect(createdArgs.description, "proposal description").toBe(evidence.description); + expect(createdArgs.values, "proposal values").toEqual(createdArgs.targets.map(() => 0n)); + expect(createdArgs.signatures, "proposal signatures").toEqual(createdArgs.targets.map(() => "")); + expect(createdArgs.calldatas, "proposal calldata count").toHaveLength(createdArgs.targets.length); + + if (params.action === "authority") { + expect(normalizeAddresses(createdArgs.targets), "authority proposal targets").toEqual( + normalizeAddresses([params.timelock, params.folio]), + ); + const roleGrants = createdArgs.calldatas.map((calldata) => + decodeFunctionData({ abi: folioArtifactAbi, data: calldata }), + ); + expect( + roleGrants.map(({ functionName }) => functionName), + "authority actions", + ).toEqual(["grantRole", "grantRole"]); + expect(roleGrants[0]?.args, "optimistic proposer authority").toEqual([OPTIMISTIC_PROPOSER_ROLE, actor]); + expect(roleGrants[1]?.args, "auction launcher authority").toEqual([AUCTION_LAUNCHER_ROLE, actor]); + } else { + expect(normalizeAddresses(createdArgs.targets), "rebalance proposal target").toEqual([params.folio.toLowerCase()]); + expect(createdArgs.calldatas, "rebalance proposal call count").toHaveLength(1); + const rebalance = decodeFunctionData({ abi: folioArtifactAbi, data: createdArgs.calldatas[0]! }); + expect(rebalance.functionName, "rebalance proposal action").toBe("startRebalance"); + if (rebalance.functionName !== "startRebalance") throw new Error("rebalance proposal must call startRebalance"); + expect(rebalance.args[0], "rebalance proposal nonce").toBe(params.nonce); + expect(normalizeAddresses(rebalance.args[1].map(({ token }) => token)), "rebalance proposal tokens").toEqual( + normalizeAddresses(params.tokens), + ); + expect(rebalance.args[5], "rebalance proposal deadline").toBe(params.deadline); + } + + expect(state, `proposal ${evidence.proposalId} state`).toBe(7); + expect(isOptimistic, `proposal ${evidence.proposalId} mechanism`).toBe(optimistic); +} + +function normalizeAddresses(addresses: readonly string[]): readonly string[] { + return addresses.map((address) => address.toLowerCase()); +} diff --git a/packages/sdk/src/index-dtf/governance/contract-map.ts b/packages/sdk/src/index-dtf/governance/contract-map.ts index a74d1fc..c805f61 100644 --- a/packages/sdk/src/index-dtf/governance/contract-map.ts +++ b/packages/sdk/src/index-dtf/governance/contract-map.ts @@ -2,6 +2,10 @@ import { getAddress, type Abi, type Address } from "viem"; import type { SupportedChainId } from "@/config"; +import { + governanceSpell09182026Abi, + INDEX_DTF_GOVERNANCE_SPELL_09_18_2026_ADDRESS, +} from "@/index-dtf/abis/governance-spell-09-18-2026"; import { dtfAdminProposalAbi, dtfIndexProposalAbi, @@ -10,6 +14,7 @@ import { timelockProposalAbi, upgradeSpellProposalAbi, } from "@/index-dtf/abis/proposal-decoder"; +import { INDEX_DTF_UPGRADE_SPELL_6_0_0_ADDRESS, upgradeSpell600Abi } from "@/index-dtf/abis/upgrade-spell-6-0-0"; import { sameAddress } from "@/lib/utils"; export type ProposalContractDecoder = { @@ -98,6 +103,16 @@ const EXTRA_PROPOSAL_CONTRACTS: readonly { 56: "0x3dde17cfd36e740cb7452cb2f59fc925eacb91ab", }, }, + { + contract: "Governance Spell 09_18_2026", + abi: governanceSpell09182026Abi, + addresses: INDEX_DTF_GOVERNANCE_SPELL_09_18_2026_ADDRESS, + }, + { + contract: "Upgrade Spell 6.0.0", + abi: upgradeSpell600Abi, + addresses: INDEX_DTF_UPGRADE_SPELL_6_0_0_ADDRESS, + }, ]; export function buildProposalContractMap({ diff --git a/packages/sdk/src/index-dtf/governance/decoder.test.ts b/packages/sdk/src/index-dtf/governance/decoder.test.ts index 69cac59..07d69de 100644 --- a/packages/sdk/src/index-dtf/governance/decoder.test.ts +++ b/packages/sdk/src/index-dtf/governance/decoder.test.ts @@ -59,6 +59,34 @@ describe("Index DTF proposal calldata decoder", () => { expect(decoded.unknownCalls).toEqual([]); }); + it("decodes Folio 6.0 self-fee, two-table fee recipient and allowlist calls", () => { + const calldatas = [ + encodeFunctionData({ abi: dtfIndexProposalAbi, functionName: "setFolioSelfFee", args: [50000000000000000n] }), + encodeFunctionData({ + abi: dtfIndexProposalAbi, + functionName: "setFeeRecipients", + args: [[{ recipient: DTF, portion: 600000000000000000n }], [{ recipient: DTF, portion: 400000000000000000n }]], + }), + encodeFunctionData({ abi: dtfIndexProposalAbi, functionName: "addToAllowlist", args: [[DTF]] }), + encodeFunctionData({ abi: dtfIndexProposalAbi, functionName: "setTradeAllowlistEnabled", args: [true] }), + ]; + + const decoded = decodeIndexDtfProposalCalldatas({ + targets: calldatas.map(() => DTF), + calldatas, + contractMap: new Map([[DTF.toLowerCase(), { target: DTF, contract: "Index DTF", abi: dtfIndexProposalAbi }]]), + }); + + expect(decoded.calls.map((call) => call.functionName)).toEqual([ + "setFolioSelfFee", + "setFeeRecipients", + "addToAllowlist", + "setTradeAllowlistEnabled", + ]); + expect(decoded.calls[1]?.params).toHaveLength(2); + expect(decoded.unknownCalls).toEqual([]); + }); + it("decodes optimistic governance and selector registry proposal calls", () => { const governance = "0x0000000000000000000000000000000000000002"; const selectorRegistry = "0x0000000000000000000000000000000000000003"; diff --git a/packages/sdk/src/index-dtf/governance/decoder.ts b/packages/sdk/src/index-dtf/governance/decoder.ts index 5d9db4c..6a4fb2a 100644 --- a/packages/sdk/src/index-dtf/governance/decoder.ts +++ b/packages/sdk/src/index-dtf/governance/decoder.ts @@ -5,6 +5,7 @@ import type { DtfClientExplorer } from "@/client/explorer"; import type { SupportedChainId } from "@/config"; import type { IndexDtfDecodedCalldata, IndexDtfProposalDecoded, IndexDtfUnknownCalldata } from "@/types/governance"; +import { ownableTransferOwnershipAbi } from "@/index-dtf/abis/governance-spell-09-18-2026"; import { selectorRegistryAbi } from "@/index-dtf/abis/selector-registry"; import { buildProposalContractMap, @@ -21,6 +22,10 @@ const FALLBACK_DECODERS: readonly Omit[] = [ contract: "Selector Registry", abi: selectorRegistryAbi, }, + { + contract: "Ownable Contract", + abi: ownableTransferOwnershipAbi, + }, ]; type DecodeIndexDtfProposalCalldatasParams = { diff --git a/packages/sdk/src/index-dtf/governance/index.ts b/packages/sdk/src/index-dtf/governance/index.ts index a5f78a3..6227e50 100644 --- a/packages/sdk/src/index-dtf/governance/index.ts +++ b/packages/sdk/src/index-dtf/governance/index.ts @@ -3,6 +3,7 @@ export * from "@/index-dtf/governance/delegates"; export * from "@/index-dtf/governance/decoder"; export * from "@/index-dtf/governance/guardians"; export * from "@/index-dtf/governance/legacy-vote-lock"; +export * from "@/index-dtf/governance/vote-lock-dependents"; export * from "@/index-dtf/governance/optimistic"; export * from "@/index-dtf/governance/proposal-actions"; export * from "@/index-dtf/governance/propose/index"; diff --git a/packages/sdk/src/index-dtf/governance/legacy-vote-lock.ts b/packages/sdk/src/index-dtf/governance/legacy-vote-lock.ts index 88ff60f..6e0e87c 100644 --- a/packages/sdk/src/index-dtf/governance/legacy-vote-lock.ts +++ b/packages/sdk/src/index-dtf/governance/legacy-vote-lock.ts @@ -14,15 +14,17 @@ export async function getLegacyVoteLocks( ): Promise { const context = await getLegacyVoteLockContext(client, params); - const legacyGovernance = context?.legacyGovernance.filter((governance) => governance.toLowerCase() !== zeroAddress); - - if (!context || !legacyGovernance || legacyGovernance.length === 0) { + if (!context) { return []; } - const legacyVoteLocks = await Promise.all( - legacyGovernance.map((governance) => readLegacyVoteLock(client, context.chainId, governance)), - ); + const legacyGovernance = context.legacyGovernance.filter((governance) => governance.toLowerCase() !== zeroAddress); + const legacyVoteLocks = [ + ...context.knownVoteLocks, + ...(await Promise.all( + legacyGovernance.map((governance) => readLegacyVoteLock(client, context.chainId, governance)), + )), + ]; const currentVoteLock = context.currentVoteLock.toLowerCase(); const result: Address[] = []; @@ -73,12 +75,14 @@ async function getLegacyVoteLockContext( readonly chainId: IndexDtf["chainId"]; readonly currentVoteLock: Address; readonly legacyGovernance: readonly Address[]; + readonly knownVoteLocks: readonly Address[]; } | null> { if ("currentVoteLock" in params) { return { chainId: params.chainId, currentVoteLock: getAddress(params.currentVoteLock), legacyGovernance: params.legacyGovernance.map((address) => getAddress(address)), + knownVoteLocks: [], }; } @@ -92,6 +96,7 @@ async function getLegacyVoteLockContext( chainId: dtf.chainId, currentVoteLock: dtf.voteLockVault.token.address, legacyGovernance: dtf.voteLockVault.legacyGovernance, + knownVoteLocks: dtf.roles.admin.legacyGovernances.map(({ voteLock }) => voteLock), }; } diff --git a/packages/sdk/src/index-dtf/governance/namespace.ts b/packages/sdk/src/index-dtf/governance/namespace.ts index bc6bf36..d3a61aa 100644 --- a/packages/sdk/src/index-dtf/governance/namespace.ts +++ b/packages/sdk/src/index-dtf/governance/namespace.ts @@ -4,6 +4,7 @@ import type { BuildIndexDtfBasketSettingsProposalParams, BuildIndexDtfDaoSettingsProposalParams, BuildIndexDtfSettingsProposalParams, + BuildIndexDtfUpgradeToV6ProposalParams, } from "@/index-dtf/governance/propose/index"; import type { GetAllIndexDtfProposalsParams, @@ -11,6 +12,7 @@ import type { GetIndexDtfGovernanceActivityParams, GetIndexDtfGuardiansParams, GetIndexDtfLegacyVoteLocksParams, + GetIndexDtfVoteLockDependentsParams, GetIndexDtfOptimisticGovernanceParams, GetIndexDtfOptimisticProposalContextParams, GetIndexDtfOptimisticProposalVoterStateParams, @@ -51,6 +53,7 @@ import { getProposerState, getTopVoters, getVoterState, + getVoteLockDependents, prepareIndexDtfCancelProposal, prepareIndexDtfExecuteProposal, prepareIndexDtfGovernorCancelProposal, @@ -79,6 +82,8 @@ import { buildIndexDtfBasketSettingsProposal, buildIndexDtfDaoSettingsProposal, buildIndexDtfSettingsProposal, + buildIndexDtfUpgradeToV6Calls, + buildIndexDtfUpgradeToV6Proposal, } from "@/index-dtf/governance/propose/index"; /** Creates the direct Index DTF governance methods for the flat root namespace. */ @@ -98,6 +103,7 @@ export function createIndexDtfGovernanceNamespace(client: DtfClient) { getDelegates: (params: GetIndexDtfDelegatesParams) => getDelegates(client, params), getGuardians: (params: GetIndexDtfGuardiansParams) => getGuardians(client, params), getLegacyVoteLocks: (params: GetIndexDtfLegacyVoteLocksParams) => getLegacyVoteLocks(client, params), + getVoteLockDependents: (params: GetIndexDtfVoteLockDependentsParams) => getVoteLockDependents(client, params), getVoterState: (params: GetIndexDtfVoterStateParams) => getVoterState(client, params), getOptimisticGovernance: (params: GetIndexDtfOptimisticGovernanceParams) => getOptimisticGovernance(client, params), getOptimisticProposalContext: (params: GetIndexDtfOptimisticProposalContextParams) => @@ -146,5 +152,8 @@ export function createIndexDtfGovernanceNamespace(client: DtfClient) { buildIndexDtfDaoSettingsProposal(client, params), buildSettingsProposal: (params: BuildIndexDtfSettingsProposalParams) => buildIndexDtfSettingsProposal(client, params), + buildUpgradeToV6Calls: buildIndexDtfUpgradeToV6Calls, + buildUpgradeToV6Proposal: (params: BuildIndexDtfUpgradeToV6ProposalParams) => + buildIndexDtfUpgradeToV6Proposal(client, params), }; } diff --git a/packages/sdk/src/index-dtf/governance/proposals.test.ts b/packages/sdk/src/index-dtf/governance/proposals.test.ts index f9942a4..35db211 100644 --- a/packages/sdk/src/index-dtf/governance/proposals.test.ts +++ b/packages/sdk/src/index-dtf/governance/proposals.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { DtfClient } from "@/client"; +import { folioV6Abi, getIndexDtfProposal } from "@/index"; import { dtfIndexProposalAbi } from "@/index-dtf/abis/proposal-decoder"; import { getAllProposals, @@ -88,6 +89,59 @@ describe("Index DTF governance proposals", () => { vi.restoreAllMocks(); }); + it.each([6, 8])( + "reads v6 proposal calldata with %i-decimal vote weights through the public SDK", + async (decimals) => { + vi.spyOn(Date, "now").mockReturnValue(1_000_000_000); + const response = createChallengedConfirmationResponse(); + const target = "0x0000000000000000000000000000000000000003"; + const calldata = encodeFunctionData({ + abi: folioV6Abi, + functionName: "setMaxAuctionLength", + args: [1800n], + }); + const queryIndex = vi.fn(async () => ({ + ...response, + proposal: { + ...response.proposal, + description: "Update v6 maximum auction length", + targets: [target], + calldatas: [calldata], + forWeightedVotes: "2500000", + quorumVotes: "1000000", + votes: [{ choice: "FOR", voter: { address: target }, weight: "2500000" }], + governance: { + ...response.proposal.governance, + token: { ...response.proposal.governance.token, token: { decimals } }, + }, + }, + })); + const client = { subgraph: { queryIndex } } as unknown as DtfClient; + + const proposal = await getIndexDtfProposal(client, { + address: target, + chainId: 8453, + proposalId: response.proposal.id, + }); + + expect(proposal.forWeightedVotes).toEqual({ raw: 2500000n, formatted: decimals === 6 ? "2.5" : "0.025" }); + expect(proposal.quorumVotes).toEqual({ raw: 1000000n, formatted: decimals === 6 ? "1" : "0.01" }); + expect(proposal.votes).toEqual([ + { voter: target, choice: "FOR", weight: { raw: 2500000n, formatted: decimals === 6 ? "2.5" : "0.025" } }, + ]); + expect(proposal.state).toBe("ACTIVE"); + expect(proposal.decoded.unknownCalls).toEqual([]); + expect(proposal.decoded.calls).toHaveLength(1); + expect(proposal.decoded.calls[0]).toMatchObject({ + target, + contract: "Index DTF", + functionName: "setMaxAuctionLength", + params: [1800n], + callData: calldata, + }); + }, + ); + it("preserves transitioned optimistic proposal veto threshold sentinel", () => { const proposal = mapIndexDtfProposalSummary( { diff --git a/packages/sdk/src/index-dtf/governance/propose/basket.test.ts b/packages/sdk/src/index-dtf/governance/propose/basket.test.ts index 919435e..437ca83 100644 --- a/packages/sdk/src/index-dtf/governance/propose/basket.test.ts +++ b/packages/sdk/src/index-dtf/governance/propose/basket.test.ts @@ -1,8 +1,10 @@ -import { decodeFunctionData, parseEther, parseUnits, type Address, type PublicClient } from "viem"; +import { decodeFunctionData, getAddress, parseEther, parseUnits, type Address, type PublicClient } from "viem"; import { describe, expect, it, vi } from "vitest"; import { createDtfClient } from "@/client"; +import { createDtfSdk } from "@/create-dtf-sdk"; import { dtfIndexAbi } from "@/index-dtf/abis/dtf-index-abi"; +import { folioArtifactAbi } from "@/index-dtf/abis/folio-artifact"; import { buildInitialBasket, getBasketSharesFromUnits, @@ -18,6 +20,30 @@ const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"; const DAI = "0x6b175474e89094c44da98b954eedeac495271d0f"; const WBTC = "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599"; +// A $1 share targets 0.5 USDC ($1) and 0.25 DAI ($2), with 50% price errors. +// Weights scale by 10^(9 + token decimals); prices by 10^(36 - token decimals). +const EXPECTED_REBALANCE_TOKENS = [ + { + token: getAddress(USDC), + weight: { low: 250_000_000_000_000n, spot: 500_000_000_000_000n, high: 1_000_000_000_000_000n }, + price: { low: 500_000_000_000_000_000_000_000_000_000n, high: 2_000_000_000_000_000_000_000_000_000_001n }, + maxAuctionSize: 100_000_000n, + inRebalance: true, + }, + { + token: getAddress(DAI), + weight: { + low: 125_000_000_000_000_000_000_000_000n, + spot: 250_000_000_000_000_000_000_000_000n, + high: 500_000_000_000_000_000_000_000_000n, + }, + price: { low: 1_000_000_000_000_000_000n, high: 4_000_000_000_000_000_001n }, + maxAuctionSize: 100_000_000_000_000_000_000n, + inRebalance: true, + }, +] as const; +const EXPECTED_REBALANCE_LIMITS = { low: 1n, spot: 1_000_000_000_000_000_000n, high: 1_000_000_000_000_000_000n }; + describe("basket conversion helpers", () => { it("converts raw units to D18 basket shares", () => { const shares = getBasketSharesFromUnits({ @@ -144,12 +170,13 @@ describe("buildIndexDtfBasketProposal", () => { }, prices: { [USDC]: 1, - [DAI]: 1, + [DAI]: 2, }, priceErrors: { [USDC]: 0.5, [DAI]: 0.5, }, + maxAuctionSizesUsd: { [USDC]: 100, [DAI]: 200 }, weightControl: true, basket: { type: "shares", @@ -180,28 +207,131 @@ describe("buildIndexDtfBasketProposal", () => { }); expect(decoded.functionName).toBe("startRebalance"); - expect(decoded.args[2]).toBe(3600n); - expect(decoded.args[3]).toBe(10_800n); - - const decodedTokens = decoded.args[0] as readonly { - readonly token: Address; - readonly inRebalance: boolean; - readonly maxAuctionSize: bigint; - readonly weight: { readonly low: bigint; readonly spot: bigint; readonly high: bigint }; - readonly price: { readonly low: bigint; readonly high: bigint }; - }[]; - - expect(decodedTokens.map((tokenParams) => tokenParams.token.toLowerCase())).toEqual([USDC, DAI]); - expect(decodedTokens.every((tokenParams) => tokenParams.inRebalance)).toBe(true); - expect(decodedTokens.every((tokenParams) => tokenParams.maxAuctionSize > 0n)).toBe(true); - expect( - decodedTokens.every( - (tokenParams) => - tokenParams.weight.low <= tokenParams.weight.spot && - tokenParams.weight.spot <= tokenParams.weight.high && - tokenParams.price.low < tokenParams.price.high, - ), - ).toBe(true); + expect(proposal.calldatas).toHaveLength(1); + expect(decoded.args).toEqual([EXPECTED_REBALANCE_TOKENS, EXPECTED_REBALANCE_LIMITS, 3600n, 10_800n]); + }); + + it.each([undefined, "6.0.0"] as const)( + "builds the full v6 basket through the SDK with version %s", + async (version) => { + const sdk = createDtfSdk({ client: testClient({ version: "6.0.0", rebalanceNonce: 7n }) }); + const proposal = await sdk.index.buildBasketProposal({ + address: DTF, + chainId: 1, + governance: GOVERNANCE, + ...(version === undefined ? {} : { version }), + supply: parseEther("1"), + currentBalances: { + [USDC]: parseUnits("1", 6), + [DAI]: 0n, + }, + prices: { + [USDC]: 1, + [DAI]: 2, + }, + priceErrors: { + [USDC]: 0.5, + [DAI]: 0.5, + }, + maxAuctionSizesUsd: { [USDC]: 100, [DAI]: 200 }, + weightControl: true, + basket: { + type: "shares", + tokens: [ + { address: USDC, share: "50" }, + { address: DAI, share: "50" }, + ], + }, + auctionLauncherWindow: 3600, + ttl: 10_800, + deadline: 2_000_000_000, + }); + + const decoded = decodeFunctionData({ + abi: folioArtifactAbi, + data: proposal.calldatas[0]!, + }); + + expect(proposal.context).toMatchObject({ + version: "6.0.0", + rebalanceNonce: 8n, + deadline: 2_000_000_000n, + }); + expect(decoded.functionName).toBe("startRebalance"); + expect(proposal.governance).toBe(GOVERNANCE); + expect(proposal.targets).toEqual([DTF]); + expect(proposal.calldatas).toHaveLength(1); + expect(decoded.args).toEqual([ + 8n, + EXPECTED_REBALANCE_TOKENS, + EXPECTED_REBALANCE_LIMITS, + 3600n, + 10_800n, + 2_000_000_000n, + ]); + }, + ); + + it("requires a deadline for v6 basket proposals", async () => { + await expect( + buildIndexDtfBasketProposal(testClient({ version: "6.0.0" }), { + address: DTF, + chainId: 1, + governance: GOVERNANCE, + supply: parseEther("1"), + currentBalances: { [USDC]: parseUnits("1", 6) }, + prices: { [USDC]: 1 }, + priceErrors: { [USDC]: 0.5 }, + weightControl: true, + basket: { type: "shares", tokens: [{ address: USDC, share: "100" }] }, + }), + ).rejects.toThrow("deadline is required"); + }); + + it("rejects v6 rebalance tokens missing from an enabled trade allowlist", async () => { + const build = (tradeAllowlist: readonly Address[]) => + buildIndexDtfBasketProposal(testClient({ version: "6.0.0", tradeAllowlist }), { + address: DTF, + chainId: 1, + governance: GOVERNANCE, + supply: parseEther("1"), + currentBalances: { [USDC]: parseUnits("1", 6), [DAI]: 0n }, + prices: { [USDC]: 1, [DAI]: 2 }, + priceErrors: { [USDC]: 0.5, [DAI]: 0.5 }, + weightControl: true, + deadline: 2_000_000_000, + basket: { + type: "shares", + tokens: [ + { address: USDC, share: "50" }, + { address: DAI, share: "50" }, + ], + }, + }); + + // Folio checks every rebalance token, the kept one included: USDC alone is not enough. + await expect(build([USDC])).rejects.toThrow( + expect.objectContaining({ code: "INVALID_INPUT", meta: { tokens: [getAddress(DAI)] } }), + ); + await expect(build([])).rejects.toThrow(/Folio__TokenNotAllowlisted/); + await expect(build([USDC, DAI])).resolves.toMatchObject({ context: { rebalanceNonce: 1n } }); + }); + + it("rejects one-token v6 rebalances", async () => { + await expect( + buildIndexDtfBasketProposal(testClient({ version: "6.0.0" }), { + address: DTF, + chainId: 1, + governance: GOVERNANCE, + supply: parseEther("1"), + currentBalances: { [USDC]: parseUnits("1", 6) }, + prices: { [USDC]: 1 }, + priceErrors: { [USDC]: 0.5 }, + weightControl: true, + deadline: 2_000_000_000, + basket: { type: "shares", tokens: [{ address: USDC, share: "100" }] }, + }), + ).rejects.toThrow("Rebalance must include at least two tokens"); }); it("builds tracking rebalance args from unit input", async () => { @@ -433,26 +563,43 @@ describe("buildIndexDtfBasketProposal", () => { }); }); -function testClient() { +function testClient( + options: { + readonly version?: "5.0.0" | "6.0.0"; + readonly rebalanceNonce?: bigint; + readonly tradeAllowlist?: readonly Address[]; + } = {}, +) { return createDtfClient({ chains: { 1: { publicClient: { - multicall: vi.fn(async ({ contracts }: { contracts: unknown[] }) => { - const tokens = [USDC, DAI, WBTC]; - const metadata: Record = { - [USDC]: ["USD Coin", "USDC", 6], - [DAI]: ["Dai Stablecoin", "DAI", 18], - [WBTC]: ["Wrapped Bitcoin", "WBTC", 8], - }; - - return contracts.map((_, index) => { - const tokenAddress = tokens[Math.floor(index / 3)]!; - const field = (index % 3) as 0 | 1 | 2; - - return metadata[tokenAddress]![field]; - }); + readContract: vi.fn(async ({ functionName }: { functionName: string }) => { + if (functionName === "version") return options.version ?? "5.0.0"; + if (functionName === "getRebalanceNonce") return options.rebalanceNonce ?? 0n; + if (functionName === "tradeAllowlistEnabled") return options.tradeAllowlist !== undefined; + if (functionName === "getTokenAllowlist") return options.tradeAllowlist ?? []; + throw new Error(`Unexpected read: ${functionName}`); }), + multicall: vi.fn( + async ({ + contracts, + }: { + contracts: readonly { address: string; functionName: "name" | "symbol" | "decimals" }[]; + }) => { + const metadata: Record = { + [USDC]: { name: "USD Coin", symbol: "USDC", decimals: 6 }, + [DAI]: { name: "Dai Stablecoin", symbol: "DAI", decimals: 18 }, + [WBTC]: { name: "Wrapped Bitcoin", symbol: "WBTC", decimals: 8 }, + }; + + return contracts.map(({ address, functionName }) => { + const value = metadata[address.toLowerCase()]?.[functionName]; + if (value === undefined) throw new Error(`Unexpected token read: ${address}.${functionName}`); + return value; + }); + }, + ), } as unknown as PublicClient, }, }, diff --git a/packages/sdk/src/index-dtf/governance/propose/basket.ts b/packages/sdk/src/index-dtf/governance/propose/basket.ts index 3e1d082..0767c00 100644 --- a/packages/sdk/src/index-dtf/governance/propose/basket.ts +++ b/packages/sdk/src/index-dtf/governance/propose/basket.ts @@ -6,6 +6,8 @@ import type { IndexDtfCall } from "@/types/governance"; import type { IndexDtf } from "@/types/index-dtf"; import { dtfIndexAbi } from "@/index-dtf/abis/dtf-index-abi"; +import { folioArtifactAbi } from "@/index-dtf/abis/folio-artifact"; +import { folioV6Abi } from "@/index-dtf/abis/folio-v6.generated"; import { DEFAULT_AUCTION_LAUNCHER_WINDOW, buildIndexDtfStartRebalance, @@ -19,9 +21,10 @@ import { type IndexDtfBasketSharesInput, type IndexDtfBasketTokenInput, type IndexDtfBasketUnitsInput, - type StartRebalanceArgsV5, + type IndexDtfStartRebalanceVersion, } from "@/index-dtf/dtf/basket/index"; -import { getDtf } from "@/index-dtf/dtf/index"; +import { getDtf, getVersion } from "@/index-dtf/dtf/index"; +import { getIndexDtfRebalanceNonce } from "@/index-dtf/rebalance/current"; import { prepareContractCall } from "@/lib/contract-call"; import { SdkError } from "@/lib/errors"; @@ -45,12 +48,15 @@ export type BuildIndexDtfBasketProposalParams = BuildIndexDtfStartRebalanceParam readonly auctionLauncherWindow?: number | bigint; readonly permissionlessWindow?: number | bigint; readonly ttl?: number | bigint; + readonly deadline?: number | bigint; }; export type BuiltIndexDtfBasketProposalContext = BuiltIndexDtfStartRebalance & { readonly chainId: DtfParams["chainId"]; readonly auctionLauncherWindow: bigint; readonly ttl: bigint; + readonly rebalanceNonce?: bigint; + readonly deadline?: bigint; }; export type BuiltIndexDtfBasketProposal = { @@ -67,15 +73,31 @@ export async function buildIndexDtfBasketProposal( ): Promise { const windows = getRebalanceWindows(params); validateBasketTokenAddresses(params); - const dtf = await getDtfForProposal(client, params); + const [dtf, version] = await Promise.all([ + getDtfForProposal(client, params), + getBasketProposalVersion(client, params), + ]); + const v6Context = + version === "6.0.0" + ? { + deadline: getRequiredDeadline(params.deadline), + rebalanceNonce: await getNextRebalanceNonce(client, params), + } + : {}; const rebalance = await buildIndexDtfStartRebalance(client, { ...params, + version, ...(dtf ? { dtf } : {}), }); + if (version === "6.0.0") { + const tradeAllowlist = await getEnabledTradeAllowlist(client, params); + if (tradeAllowlist) assertRebalanceTokensAllowlisted(rebalance.startRebalanceArgs.tokens, tradeAllowlist); + } const context: BuiltIndexDtfBasketProposalContext = { ...rebalance, chainId: params.chainId, ...windows, + ...v6Context, }; const authority = getProposalAuthority(params, dtf); const call = prepareIndexDtfBasketRebalance(context); @@ -90,14 +112,41 @@ export async function buildIndexDtfBasketProposal( } function prepareIndexDtfBasketRebalance(context: BuiltIndexDtfBasketProposalContext): IndexDtfCall { - const args = getStartRebalanceArgs(context); + const tokens = context.startRebalanceArgs.tokens.map((token) => ({ + ...token, + token: getAddress(token.token as Address), + })); + + if (context.version === "6.0.0") { + if (context.rebalanceNonce === undefined || context.deadline === undefined) { + throw new SdkError({ + code: "INVALID_INPUT", + message: "rebalanceNonce and deadline are required for Index DTF 6.0.0", + }); + } + + return prepareContractCall({ + chainId: context.chainId, + address: context.address, + abi: folioArtifactAbi, + functionName: "startRebalance", + args: [ + context.rebalanceNonce, + tokens, + context.startRebalanceArgs.limits, + context.auctionLauncherWindow, + context.ttl, + context.deadline, + ] as const, + }); + } return prepareContractCall({ chainId: context.chainId, address: context.address, abi: dtfIndexAbi, functionName: "startRebalance", - args, + args: [tokens, context.startRebalanceArgs.limits, context.auctionLauncherWindow, context.ttl] as const, }); } @@ -116,18 +165,83 @@ async function getDtfForProposal( return getDtf(client, params); } -function getStartRebalanceArgs(context: BuiltIndexDtfBasketProposalContext) { - const startRebalanceArgs = context.startRebalanceArgs as StartRebalanceArgsV5; - - return [ - startRebalanceArgs.tokens.map((token) => ({ - ...token, - token: getAddress(token.token as Address), - })), - startRebalanceArgs.limits, - context.auctionLauncherWindow, - context.ttl, - ] as const; +async function getBasketProposalVersion( + client: DtfClient, + params: BuildIndexDtfBasketProposalParams, +): Promise { + const version = params.version ?? (await getVersion(client, params)); + + if (version !== "5.0.0" && version !== "6.0.0") { + throw new SdkError({ + code: "INVALID_INPUT", + message: `Unsupported Index DTF basket proposal version: ${version}`, + meta: { version }, + }); + } + + return version; +} + +async function getNextRebalanceNonce(client: DtfClient, params: DtfParams): Promise { + return (await getIndexDtfRebalanceNonce(client, params)) + 1n; +} + +/** The Folio 6.0 trade allowlist when enforcement is on; `undefined` when every token may trade. */ +async function getEnabledTradeAllowlist( + client: DtfClient, + params: DtfParams, +): Promise | undefined> { + const address = getAddress(params.address); + const enabled = await client.viem.readContract({ + chainId: params.chainId, + address, + abi: folioV6Abi, + functionName: "tradeAllowlistEnabled", + blockNumber: params.blockNumber, + }); + if (!enabled) return undefined; + + const tokens = await client.viem.readContract({ + chainId: params.chainId, + address, + abi: folioV6Abi, + functionName: "getTokenAllowlist", + blockNumber: params.blockNumber, + }); + + return new Set(tokens.map((token) => getAddress(token))); +} + +/** + * Folio 6.0 `startRebalance` reverts `Folio__TokenNotAllowlisted` for every rebalance token (kept or new) missing from + * an enabled allowlist. Checked at build time; governance can still change the allowlist before execution. + */ +function assertRebalanceTokensAllowlisted( + tokens: BuiltIndexDtfStartRebalance["startRebalanceArgs"]["tokens"], + allowlist: ReadonlySet
, +) { + const notAllowlisted = tokens + .map(({ token }) => getAddress(token as Address)) + .filter((token) => !allowlist.has(token)); + + if (notAllowlisted.length > 0) { + throw new SdkError({ + code: "INVALID_INPUT", + message: `The Folio's trade allowlist is enabled and does not include ${notAllowlisted.join(", ")}; startRebalance would revert with Folio__TokenNotAllowlisted`, + meta: { tokens: notAllowlisted }, + }); + } +} + +function getRequiredDeadline(deadline: number | bigint | undefined): bigint { + if (deadline === undefined) { + throw new SdkError({ + code: "INVALID_INPUT", + message: "deadline is required to build an Index DTF 6.0.0 basket proposal", + }); + } + + return toSeconds(deadline, "deadline", { allowZero: false }); } function getProposalAuthority( diff --git a/packages/sdk/src/index-dtf/governance/propose/calls.test.ts b/packages/sdk/src/index-dtf/governance/propose/calls.test.ts index a50689c..1f4174e 100644 --- a/packages/sdk/src/index-dtf/governance/propose/calls.test.ts +++ b/packages/sdk/src/index-dtf/governance/propose/calls.test.ts @@ -7,7 +7,11 @@ import { OPTIMISTIC_PROPOSER_ROLE } from "@/index-dtf/governance/optimistic"; import { indexDtfV5WriteAbi, indexDtfV6WriteAbi, + prepareIndexDtfAddToAllowlist, prepareIndexDtfDeprecate, + prepareIndexDtfRemoveFromAllowlist, + prepareIndexDtfSetSelfFee, + prepareIndexDtfSetTradeAllowlistEnabled, prepareIndexDtfRelay, prepareIndexDtfRevokeOptimisticProposer, prepareIndexDtfSetAuctionLength, @@ -24,6 +28,7 @@ import { const DTF = "0x0000000000000000000000000000000000000001"; const ACCOUNT = "0x0000000000000000000000000000000000000002"; +const IMMUTABLE_ACCOUNT = "0x0000000000000000000000000000000000000003"; describe("Index DTF call builders", () => { it("encodes simple setter calls", () => { @@ -76,6 +81,108 @@ describe("Index DTF call builders", () => { ); }); + it("encodes v6 mutable and immutable fee recipient tables", () => { + const call = prepareIndexDtfSetFeeRecipients({ + address: DTF, + chainId: 1, + version: "6.0.0", + recipients: [{ recipient: ACCOUNT, portion: 600000000000000000n }], + immutableRecipients: [{ recipient: IMMUTABLE_ACCOUNT, portion: 400000000000000000n }], + }); + const decoded = decodeFunctionData({ abi: indexDtfV6WriteAbi, data: call.data }); + + expect(decoded.functionName).toBe("setFeeRecipients"); + expect(decoded.args).toEqual([ + [{ recipient: ACCOUNT, portion: 600000000000000000n }], + [{ recipient: IMMUTABLE_ACCOUNT, portion: 400000000000000000n }], + ]); + }); + + it("sorts v6 fee tables and rejects tables that do not total 100%", () => { + const call = prepareIndexDtfSetFeeRecipients({ + address: DTF, + chainId: 1, + version: "6.0.0", + recipients: [ + { recipient: IMMUTABLE_ACCOUNT, portion: 300000000000000000n }, + { recipient: ACCOUNT, portion: 300000000000000000n }, + ], + immutableRecipients: [{ recipient: "0x0000000000000000000000000000000000000004", portion: 400000000000000000n }], + }); + expect(decodeFunctionData({ abi: indexDtfV6WriteAbi, data: call.data }).args).toEqual([ + [ + { recipient: ACCOUNT, portion: 300000000000000000n }, + { recipient: IMMUTABLE_ACCOUNT, portion: 300000000000000000n }, + ], + [{ recipient: "0x0000000000000000000000000000000000000004", portion: 400000000000000000n }], + ]); + expect(() => + prepareIndexDtfSetFeeRecipients({ + address: DTF, + chainId: 1, + version: "6.0.0", + recipients: [{ recipient: DTF, portion: 1000000000000000000n }], + immutableRecipients: [], + }), + ).toThrow("not the Folio itself"); + expect(() => + prepareIndexDtfSetFeeRecipients({ + address: DTF, + chainId: 1, + version: "6.0.0", + recipients: [{ recipient: ACCOUNT, portion: 600000000000000000n }], + immutableRecipients: [{ recipient: IMMUTABLE_ACCOUNT, portion: 300000000000000000n }], + }), + ).toThrow("must total 100%"); + }); + + it("encodes v6 self-fee and allowlist calls and rejects them on v5", () => { + const selfFee = prepareIndexDtfSetSelfFee({ address: DTF, chainId: 1, version: "6.0.0", percentage: 5 }); + const enable = prepareIndexDtfSetTradeAllowlistEnabled({ + address: DTF, + chainId: 1, + version: "6.0.0", + enabled: true, + }); + const add = prepareIndexDtfAddToAllowlist({ address: DTF, chainId: 1, version: "6.0.0", tokens: [ACCOUNT] }); + const remove = prepareIndexDtfRemoveFromAllowlist({ + address: DTF, + chainId: 1, + version: "6.0.0", + tokens: [ACCOUNT], + }); + + expect( + [selfFee, enable, add, remove].map((call) => decodeFunctionData({ abi: indexDtfV6WriteAbi, data: call.data })), + ).toEqual([ + { functionName: "setFolioSelfFee", args: [50000000000000000n] }, + { functionName: "setTradeAllowlistEnabled", args: [true] }, + { functionName: "addToAllowlist", args: [[ACCOUNT]] }, + { functionName: "removeFromAllowlist", args: [[ACCOUNT]] }, + ]); + expect([selfFee, enable, add, remove].every((call) => call.to === DTF && call.value === 0n)).toBe(true); + expect(() => prepareIndexDtfSetSelfFee({ address: DTF, chainId: 1, version: "6.0.0", percentage: 101 })).toThrow( + "between 0 and 100", + ); + expect(() => prepareIndexDtfSetSelfFee({ address: DTF, chainId: 1, version: "5.0.0", percentage: 5 })).toThrow( + "setFolioSelfFee is not supported by Index DTF 5.0.0", + ); + expect(() => + prepareIndexDtfAddToAllowlist({ address: DTF, chainId: 1, version: "5.0.0", tokens: [ACCOUNT] }), + ).toThrow("addToAllowlist is not supported by Index DTF 5.0.0"); + }); + + it("requires the immutable fee recipient table for v6", () => { + expect(() => + prepareIndexDtfSetFeeRecipients({ + address: DTF, + chainId: 1, + version: "6.0.0", + recipients: [{ recipient: ACCOUNT, portion: 1n }], + }), + ).toThrow("immutableRecipients is required"); + }); + it("keeps unchanged no-arg calls available on older versions", () => { const call = prepareIndexDtfDeprecate({ address: DTF, @@ -188,3 +295,15 @@ describe("Index DTF call builders", () => { expect(batch.contract.args[1]).toEqual([0n]); }); }); + +describe("settings builders reject unsupported write versions", () => { + it.each(["4.0.0", "5.1.0", undefined])("setAuctionLength / setFeeRecipients reject %s", (version) => { + const base = { address: "0x0000000000000000000000000000000000000001", chainId: 1 } as const; + expect(() => + prepareIndexDtfSetAuctionLength({ ...base, version: version as never, auctionLength: 1800n } as never), + ).toThrow(/Unsupported Index DTF version/); + expect(() => + prepareIndexDtfSetFeeRecipients({ ...base, version: version as never, recipients: [] } as never), + ).toThrow(/Unsupported Index DTF version/); + }); +}); diff --git a/packages/sdk/src/index-dtf/governance/propose/calls.ts b/packages/sdk/src/index-dtf/governance/propose/calls.ts index 9d72545..8996c54 100644 --- a/packages/sdk/src/index-dtf/governance/propose/calls.ts +++ b/packages/sdk/src/index-dtf/governance/propose/calls.ts @@ -1,29 +1,39 @@ -import { parseEther, zeroHash, type Address, type Hex } from "viem"; +import { getAddress, parseEther, zeroHash, type Address, type Hex } from "viem"; import type { SupportedChainId } from "@/config"; import type { IndexDtfCall } from "@/types/governance"; import type { PriceControl } from "@/types/index-dtf"; -import { dtfIndexAbi as indexDtfV5Abi } from "@/index-dtf/abis/dtf-index-abi"; import { dtfIndexGovernanceAbi } from "@/index-dtf/abis/dtf-index-governance"; import { dtfIndexGovernanceOptimisticAbi } from "@/index-dtf/abis/dtf-index-governance-optimistic"; -import { folioArtifactAbi as indexDtfV6Abi } from "@/index-dtf/abis/folio-artifact"; import { timelockAbi } from "@/index-dtf/abis/timelock"; +import { + assertIndexDtfFeeRecipientTables, + sortIndexDtfFeeRecipients, + type IndexDtfFeeRecipient, +} from "@/index-dtf/fee-recipients"; import { OPTIMISTIC_PROPOSER_ROLE } from "@/index-dtf/governance/optimistic"; +import { + assertIndexDtfWriteVersion, + getIndexDtfWriteAbi, + indexDtfV5WriteAbi, + indexDtfV6WriteAbi, + type IndexDtfWriteVersion, +} from "@/index-dtf/write-version"; import { prepareContractCall } from "@/lib/contract-call"; import { Decimal } from "@/lib/decimal"; import { SdkError } from "@/lib/errors"; import { toUint, toUintNumber } from "@/lib/utils"; -export const indexDtfV5WriteAbi = indexDtfV5Abi; -export const indexDtfV6WriteAbi = indexDtfV6Abi; +export { + assertIndexDtfWriteVersion, + getIndexDtfWriteAbi, + indexDtfV5WriteAbi, + indexDtfV6WriteAbi, + type IndexDtfWriteVersion, +} from "@/index-dtf/write-version"; -export type IndexDtfWriteVersion = "5.0.0" | "6.0.0"; - -export type IndexDtfFeeRecipient = { - readonly recipient: Address; - readonly portion: bigint; -}; +export type { IndexDtfFeeRecipient } from "@/index-dtf/fee-recipients"; export type PrepareIndexDtfCallParams = { readonly address: Address; @@ -113,11 +123,19 @@ export function prepareIndexDtfSetSelfFee(params: PrepareIndexDtfPercentageCallP if (params.version !== "6.0.0") { throw new SdkError({ code: "INVALID_INPUT", - message: `setSelfFee is not supported by Index DTF ${params.version}`, + message: `setFolioSelfFee is not supported by Index DTF ${params.version}`, meta: { version: params.version }, }); } + if (!Number.isFinite(params.percentage) || params.percentage < 0 || params.percentage > 100) { + throw new SdkError({ + code: "INVALID_INPUT", + message: "selfFee percentage must be between 0 and 100", + meta: { percentage: params.percentage }, + }); + } + return prepareContractCall({ chainId: params.chainId, address: params.address, @@ -130,23 +148,56 @@ export function prepareIndexDtfSetSelfFee(params: PrepareIndexDtfPercentageCallP export function prepareIndexDtfSetFeeRecipients( params: PrepareIndexDtfCallParams & { readonly recipients: readonly IndexDtfFeeRecipient[]; + readonly immutableRecipients?: readonly IndexDtfFeeRecipient[]; }, ): IndexDtfCall { + assertIndexDtfWriteVersion(params.version); + const recipients = params.recipients.map((recipient) => ({ + recipient: getAddress(recipient.recipient), + portion: recipient.portion, + })); + + if (params.version === "6.0.0") { + if (params.immutableRecipients === undefined) { + throw new SdkError({ + code: "INVALID_INPUT", + message: "immutableRecipients is required for Index DTF 6.0.0 fee recipient calls", + }); + } + + const tables = { + recipients: sortIndexDtfFeeRecipients(recipients), + immutableRecipients: sortIndexDtfFeeRecipients(params.immutableRecipients), + }; + assertIndexDtfFeeRecipientTables({ ...tables, folio: params.address }); + + return prepareContractCall({ + chainId: params.chainId, + address: params.address, + abi: indexDtfV6WriteAbi, + functionName: "setFeeRecipients", + args: [tables.recipients, tables.immutableRecipients] as const, + }); + } + + if (params.immutableRecipients?.length) { + throw new SdkError({ + code: "INVALID_INPUT", + message: "immutable fee recipients are only supported by Index DTF 6.0.0", + }); + } + return prepareContractCall({ chainId: params.chainId, address: params.address, - abi: getIndexDtfWriteAbi(params.version), + abi: indexDtfV5WriteAbi, functionName: "setFeeRecipients", - args: [ - params.recipients.map((recipient) => ({ - recipient: recipient.recipient, - portion: recipient.portion, - })), - ] as never, + args: [recipients] as const, }); } export function prepareIndexDtfSetAuctionLength(params: PrepareIndexDtfAuctionLengthCallParams): IndexDtfCall { + assertIndexDtfWriteVersion(params.version); if (params.version === "6.0.0") { return prepareContractCall({ chainId: params.chainId, @@ -484,7 +535,3 @@ function toSeconds(value: number | bigint): bigint { return BigInt(Math.round(value)); } - -function getIndexDtfWriteAbi(version: IndexDtfWriteVersion) { - return version === "6.0.0" ? indexDtfV6WriteAbi : indexDtfV5WriteAbi; -} diff --git a/packages/sdk/src/index-dtf/governance/propose/index.ts b/packages/sdk/src/index-dtf/governance/propose/index.ts index b96d004..83c10db 100644 --- a/packages/sdk/src/index-dtf/governance/propose/index.ts +++ b/packages/sdk/src/index-dtf/governance/propose/index.ts @@ -1,2 +1,3 @@ export * from "@/index-dtf/governance/propose/basket"; export * from "@/index-dtf/governance/propose/settings"; +export * from "@/index-dtf/governance/propose/upgrade-v6"; diff --git a/packages/sdk/src/index-dtf/governance/propose/revenue.ts b/packages/sdk/src/index-dtf/governance/propose/revenue.ts index c849c05..4a75951 100644 --- a/packages/sdk/src/index-dtf/governance/propose/revenue.ts +++ b/packages/sdk/src/index-dtf/governance/propose/revenue.ts @@ -3,11 +3,16 @@ import { getAddress, parseEther, type Address } from "viem"; import type { IndexDtfCall } from "@/types/governance"; import type { IndexDtf } from "@/types/index-dtf"; +import { + INDEX_DTF_FEE_RECIPIENT_TOTAL, + INDEX_DTF_MAX_FEE_RECIPIENTS, + scaleIndexDtfFeeRecipients, + type IndexDtfFeeRecipient, +} from "@/index-dtf/fee-recipients"; import { prepareIndexDtfSetFeeRecipients } from "@/index-dtf/governance/propose/calls"; import { Decimal } from "@/lib/decimal"; import { SdkError } from "@/lib/errors"; -const MAX_FEE_RECIPIENTS = 64; const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; const RECIPIENT_SHARE_TOLERANCE = new Decimal("0.000000000001"); @@ -25,10 +30,7 @@ export type IndexDtfRevenueDistributionInput = { readonly additionalRecipients: readonly IndexDtfRevenueRecipientInput[]; }; -export type IndexDtfFeeRecipient = { - readonly recipient: Address; - readonly portion: bigint; -}; +export type { IndexDtfFeeRecipient } from "@/index-dtf/fee-recipients"; export type BuildIndexDtfFeeRecipientsParams = IndexDtfRevenueDistributionInput & { readonly deployer: Address; @@ -43,6 +45,8 @@ export function prepareRevenueDistribution( dtf: IndexDtf | undefined, distribution: IndexDtfRevenueDistributionInput | undefined, version: "5.0.0" | "6.0.0", + /** Required on 6.0.0: the current immutable table, passed back unchanged. */ + immutableRecipients: readonly IndexDtfFeeRecipient[] | undefined, ): RevenueDistributionCall | undefined { if (!distribution) { return undefined; @@ -59,6 +63,34 @@ export function prepareRevenueDistribution( ...(dtf.voteLockVault ? { voteLock: dtf.voteLockVault.token.address } : {}), }); + if (version === "6.0.0") { + if (immutableRecipients === undefined) { + throw new SdkError({ + code: "INVALID_INPUT", + message: "Index DTF 6.0.0 revenue proposals require the full immutable fee recipient table", + }); + } + // The immutable table is preserved as-is; the mutable shares split whatever it leaves. + const immutableTotal = immutableRecipients.reduce((sum, recipient) => sum + recipient.portion, 0n); + const remainder = INDEX_DTF_FEE_RECIPIENT_TOTAL - immutableTotal; + + if (recipients.length > 0 && remainder <= 0n) { + throw new SdkError({ + code: "INVALID_INPUT", + message: "immutable fee recipients already take the full fee; no mutable share is left to distribute", + meta: { immutableTotal }, + }); + } + + return prepareIndexDtfSetFeeRecipients({ + address: dtfAddress, + chainId, + version, + recipients: scaleIndexDtfFeeRecipients(recipients, remainder), + immutableRecipients, + }); + } + return recipients.length > 0 ? prepareIndexDtfSetFeeRecipients({ address: dtfAddress, chainId, recipients, version }) : undefined; @@ -201,10 +233,10 @@ export function buildIndexDtfFeeRecipients({ } function validateFeeRecipients(recipients: readonly { readonly recipient: Address; readonly portion: bigint }[]) { - if (recipients.length > MAX_FEE_RECIPIENTS) { + if (recipients.length > INDEX_DTF_MAX_FEE_RECIPIENTS) { throw new SdkError({ code: "INVALID_INPUT", - message: `fee recipients cannot exceed ${MAX_FEE_RECIPIENTS}`, + message: `fee recipients cannot exceed ${INDEX_DTF_MAX_FEE_RECIPIENTS}`, meta: { count: recipients.length }, }); } @@ -237,10 +269,10 @@ function validateRawAdditionalRecipients( recipients: readonly IndexDtfRevenueRecipientInput[], reservedRecipients: readonly Address[], ) { - if (recipients.length > MAX_FEE_RECIPIENTS) { + if (recipients.length > INDEX_DTF_MAX_FEE_RECIPIENTS) { throw new SdkError({ code: "INVALID_INPUT", - message: `fee recipients cannot exceed ${MAX_FEE_RECIPIENTS}`, + message: `fee recipients cannot exceed ${INDEX_DTF_MAX_FEE_RECIPIENTS}`, meta: { count: recipients.length }, }); } diff --git a/packages/sdk/src/index-dtf/governance/propose/settings-dtf.ts b/packages/sdk/src/index-dtf/governance/propose/settings-dtf.ts index d80c13d..995c3be 100644 --- a/packages/sdk/src/index-dtf/governance/propose/settings-dtf.ts +++ b/packages/sdk/src/index-dtf/governance/propose/settings-dtf.ts @@ -10,7 +10,10 @@ import type { IndexDtfCall } from "@/types/governance"; import { dtfIndexAbi } from "@/index-dtf/abis/dtf-index-abi"; import { timelockAbi } from "@/index-dtf/abis/timelock"; +import { getIndexDtfImmutableFeeRecipients } from "@/index-dtf/dtf/folio-v6"; import { + prepareIndexDtfAddToAllowlist, + prepareIndexDtfRemoveFromAllowlist, prepareIndexDtfRemoveFromBasket, prepareIndexDtfSetAuctionLength, prepareIndexDtfSetBidsEnabled, @@ -18,6 +21,8 @@ import { prepareIndexDtfSetMintFee, prepareIndexDtfSetName, prepareIndexDtfSetRebalanceControl, + prepareIndexDtfSetSelfFee, + prepareIndexDtfSetTradeAllowlistEnabled, prepareIndexDtfSetTvlFee, } from "@/index-dtf/governance/propose/calls"; import { prepareRevenueDistribution, validateRevenueDistributionInput } from "@/index-dtf/governance/propose/revenue"; @@ -125,9 +130,28 @@ async function buildIndexDtfSettingsCalls( }), ); + if (version === "5.0.0" && params.immutableFeeRecipients !== undefined) { + throw new SdkError({ + code: "INVALID_INPUT", + message: "immutableFeeRecipients is not supported by Index DTF 5.0.0", + meta: { version }, + }); + } + const immutableRecipients = + version === "6.0.0" && params.revenueDistribution + ? (params.immutableFeeRecipients ?? + (await getIndexDtfImmutableFeeRecipients(client, { address: dtfAddress, chainId: params.chainId }))) + : undefined; const revenueCall = version && params.revenueDistribution - ? prepareRevenueDistribution(dtfAddress, params.chainId, dtf, params.revenueDistribution, version) + ? prepareRevenueDistribution( + dtfAddress, + params.chainId, + dtf, + params.revenueDistribution, + version, + immutableRecipients, + ) : undefined; if (revenueCall) calls.push(revenueCall); @@ -189,6 +213,30 @@ function buildDtfCalls( calls.push( prepareIndexDtfSetBidsEnabled({ address, chainId: params.chainId, enabled: params.bidsEnabled, version }), ); + if (params.selfFee !== undefined) + calls.push(prepareIndexDtfSetSelfFee({ address, chainId: params.chainId, percentage: params.selfFee, version })); + if (params.tradeAllowlist?.add?.length) + calls.push( + prepareIndexDtfAddToAllowlist({ address, chainId: params.chainId, tokens: params.tradeAllowlist.add, version }), + ); + if (params.tradeAllowlist?.remove?.length) + calls.push( + prepareIndexDtfRemoveFromAllowlist({ + address, + chainId: params.chainId, + tokens: params.tradeAllowlist.remove, + version, + }), + ); + if (params.tradeAllowlist?.enabled !== undefined) + calls.push( + prepareIndexDtfSetTradeAllowlistEnabled({ + address, + chainId: params.chainId, + enabled: params.tradeAllowlist.enabled, + version, + }), + ); return calls; } diff --git a/packages/sdk/src/index-dtf/governance/propose/settings-shared.ts b/packages/sdk/src/index-dtf/governance/propose/settings-shared.ts index 6d581cc..2b2ec50 100644 --- a/packages/sdk/src/index-dtf/governance/propose/settings-shared.ts +++ b/packages/sdk/src/index-dtf/governance/propose/settings-shared.ts @@ -17,6 +17,7 @@ const MAX_MINT_FEE = 5; const MAX_TVL_FEE = 10; const MIN_AUCTION_LENGTH_MINUTES = 15; const MAX_AUCTION_LENGTH_MINUTES = 1440; +const MAX_SELF_FEE_PERCENT = 100; export function buildSettingsProposal({ calls, @@ -93,6 +94,10 @@ export function hasIndexDtfSettingsCall(params: BuildIndexDtfSettingsProposalPar params.mintFee !== undefined || params.tvlFee !== undefined || params.auctionLength !== undefined || + params.selfFee !== undefined || + params.tradeAllowlist?.enabled !== undefined || + (params.tradeAllowlist?.add?.length ?? 0) > 0 || + (params.tradeAllowlist?.remove?.length ?? 0) > 0 || params.weightControl !== undefined || params.priceControl !== undefined || params.bidsEnabled !== undefined || @@ -110,6 +115,7 @@ export function validateDtfSettingsParams(params: BuildIndexDtfSettingsProposalP if (params.tvlFee !== undefined) assertNumberRange(params.tvlFee, "tvlFee", 0, MAX_TVL_FEE); if (params.auctionLength !== undefined) assertNumberRange(params.auctionLength, "auctionLength", MIN_AUCTION_LENGTH_MINUTES, MAX_AUCTION_LENGTH_MINUTES); + if (params.selfFee !== undefined) assertNumberRange(params.selfFee, "selfFee", 0, MAX_SELF_FEE_PERCENT); if (params.priceControl !== undefined && !isPriceControl(params.priceControl)) { throw new SdkError({ code: "INVALID_INPUT", diff --git a/packages/sdk/src/index-dtf/governance/propose/settings-types.ts b/packages/sdk/src/index-dtf/governance/propose/settings-types.ts index fad2ee0..964caff 100644 --- a/packages/sdk/src/index-dtf/governance/propose/settings-types.ts +++ b/packages/sdk/src/index-dtf/governance/propose/settings-types.ts @@ -1,6 +1,7 @@ import { isAddress, type Address, type Hex } from "viem"; import { z } from "zod"; +import type { IndexDtfFeeRecipient } from "@/index-dtf/fee-recipients"; import type { IndexDtfRevenueDistributionInput } from "@/index-dtf/governance/propose/revenue"; import type { DtfParams } from "@/types/common"; import type { IndexDtfCall } from "@/types/governance"; @@ -34,6 +35,14 @@ export const indexDtfSettingsProposalSchema = z.object({ mintFee: z.coerce.number().min(0).max(5).optional(), tvlFee: z.coerce.number().min(0).max(10).optional(), auctionLength: z.coerce.number().min(15).max(1440).optional(), + selfFee: z.coerce.number().min(0).max(100).optional(), + tradeAllowlist: z + .object({ + enabled: z.boolean().optional(), + add: z.array(addressSchema).optional(), + remove: z.array(addressSchema).optional(), + }) + .optional(), weightControl: z.boolean().optional(), priceControl: z.coerce.number().int().min(0).max(2).optional(), bidsEnabled: z.boolean().optional(), @@ -117,6 +126,10 @@ export type BuildIndexDtfSettingsProposalParams = DtfParams & { readonly mintFee?: number; readonly tvlFee?: number; readonly auctionLength?: number; + /** Folio 6.0 only: percent of non-DAO fees kept for holders (`setFolioSelfFee`). */ + readonly selfFee?: number; + /** Folio 6.0 only: allowlist membership and enforcement (`addToAllowlist`, `removeFromAllowlist`, `setTradeAllowlistEnabled`). */ + readonly tradeAllowlist?: IndexDtfTradeAllowlistChanges; readonly weightControl?: boolean; readonly priceControl?: PriceControl; readonly bidsEnabled?: boolean; @@ -128,9 +141,17 @@ export type BuildIndexDtfSettingsProposalParams = DtfParams & { readonly governanceChanges?: IndexDtfGovernanceChanges; readonly quorumDenominator?: number; readonly revenueDistribution?: IndexDtfRevenueDistributionInput; + /** Folio 6.0 only: the current immutable table for a revenue change; read from RPC when omitted. */ + readonly immutableFeeRecipients?: readonly IndexDtfFeeRecipient[]; readonly version?: "5.0.0" | "6.0.0" | undefined; }; +export type IndexDtfTradeAllowlistChanges = { + readonly enabled?: boolean; + readonly add?: readonly Address[]; + readonly remove?: readonly Address[]; +}; + export type { IndexDtfRevenueDistributionInput, IndexDtfRevenueRecipientInput, diff --git a/packages/sdk/src/index-dtf/governance/propose/settings.test.ts b/packages/sdk/src/index-dtf/governance/propose/settings.test.ts index 73e5fe5..c9ee575 100644 --- a/packages/sdk/src/index-dtf/governance/propose/settings.test.ts +++ b/packages/sdk/src/index-dtf/governance/propose/settings.test.ts @@ -1,4 +1,10 @@ -import { decodeFunctionData, parseEther, type PublicClient } from "viem"; +import { + ContractFunctionExecutionError, + ContractFunctionZeroDataError, + decodeFunctionData, + parseEther, + type PublicClient, +} from "viem"; import { describe, expect, it, vi } from "vitest"; import { createDtfClient } from "@/client"; @@ -220,8 +226,10 @@ describe("settings proposal builders", () => { indexDtfSettingsProposalSchema.parse({ auctionLength: 30, version: "6.0.0", + selfFee: "5", + tradeAllowlist: { enabled: true, add: [REWARD] }, }), - ).toMatchObject({ version: "6.0.0" }); + ).toMatchObject({ version: "6.0.0", selfFee: 5, tradeAllowlist: { enabled: true, add: [REWARD] } }); }); it("requires timelock to build settings proposals", async () => { @@ -595,6 +603,160 @@ describe("settings proposal builders", () => { expect(total).toBe(parseEther("1")); }); + it("fits a v6 revenue distribution next to an explicit immutable table", async () => { + const proposal = await buildIndexDtfSettingsProposal({} as never, { + address: DTF, + chainId: 1, + governance: GOVERNANCE, + dtf: createDtfContext(), + version: "6.0.0", + immutableFeeRecipients: [{ recipient: ST_TOKEN, portion: parseEther("0.25") }], + revenueDistribution: { + platformFee: 20, + governanceShare: 0, + deployerShare: 40, + additionalRecipients: [{ address: GUARDIAN_A, share: 40 }], + }, + }); + + const decoded = decodeFunctionData({ abi: folioArtifactAbi, data: proposal.calldatas[0]! }); + expect(decoded.functionName).toBe("setFeeRecipients"); + expect(decoded.args).toEqual([ + [ + { recipient: GUARDIAN_A, portion: parseEther("0.375") }, + { recipient: DEPLOYER, portion: parseEther("0.375") }, + ], + [{ recipient: ST_TOKEN, portion: parseEther("0.25") }], + ]); + }); + + it("reads the immutable table from RPC for a v6 revenue distribution when none is passed", async () => { + const multicall = vi.fn(async ({ contracts }: { contracts: readonly { args?: readonly unknown[] }[] }) => + contracts.map(({ args }) => + Number(args?.[0]) === 0 + ? { status: "success", result: [ST_TOKEN, parseEther("0.5")] } + : { status: "failure", error: new Error("revert") }, + ), + ); + const readContract = vi.fn(async ({ functionName }: { functionName: string }) => { + if (functionName === "folioFeeForSelf") return 0n; + throw new ContractFunctionExecutionError(new ContractFunctionZeroDataError({ functionName }), { + abi: folioArtifactAbi, + functionName, + args: [0n], + }); + }); + const getBlockNumber = vi.fn(async () => 1n); + const client = createDtfClient({ + chains: { 1: { publicClient: { multicall, readContract, getBlockNumber } as unknown as PublicClient } }, + }); + + const proposal = await buildIndexDtfSettingsProposal(client, { + address: DTF, + chainId: 1, + governance: GOVERNANCE, + dtf: createDtfContext(), + version: "6.0.0", + revenueDistribution: { platformFee: 20, governanceShare: 0, deployerShare: 80, additionalRecipients: [] }, + }); + + expect(decodeFunctionData({ abi: folioArtifactAbi, data: proposal.calldatas[0]! }).args).toEqual([ + [{ recipient: DEPLOYER, portion: parseEther("0.5") }], + [{ recipient: ST_TOKEN, portion: parseEther("0.5") }], + ]); + }); + + it("rejects a v6 revenue distribution when the immutable table already takes 100%", async () => { + await expect( + buildIndexDtfSettingsProposal({} as never, { + address: DTF, + chainId: 1, + governance: GOVERNANCE, + dtf: createDtfContext(), + version: "6.0.0", + immutableFeeRecipients: [{ recipient: ST_TOKEN, portion: parseEther("1") }], + revenueDistribution: { platformFee: 20, governanceShare: 0, deployerShare: 80, additionalRecipients: [] }, + }), + ).rejects.toThrow("no mutable share is left"); + }); + + it("builds v6 self-fee and allowlist settings calls in one proposal", async () => { + const proposal = await buildIndexDtfSettingsProposal({} as never, { + address: DTF, + chainId: 1, + governance: GOVERNANCE, + timelock: TIMELOCK, + version: "6.0.0", + selfFee: 5, + tradeAllowlist: { add: [REWARD], remove: [GUARDIAN_B], enabled: true }, + }); + + const decoded = proposal.calldatas.map((data) => decodeFunctionData({ abi: folioArtifactAbi, data })); + expect(decoded.map((call) => call.functionName)).toEqual([ + "setFolioSelfFee", + "addToAllowlist", + "removeFromAllowlist", + "setTradeAllowlistEnabled", + ]); + expect(decoded[0]?.args).toEqual([parseEther("0.05")]); + expect(decoded[1]?.args).toEqual([[REWARD]]); + expect(decoded[3]?.args).toEqual([true]); + }); + + it("rejects v6-only settings on a v5 DTF by name", async () => { + await expect( + buildIndexDtfSettingsProposal({} as never, { + address: DTF, + chainId: 1, + governance: GOVERNANCE, + timelock: TIMELOCK, + version: "5.0.0", + selfFee: 5, + }), + ).rejects.toThrow("setFolioSelfFee is not supported by Index DTF 5.0.0"); + await expect( + buildIndexDtfSettingsProposal({} as never, { + address: DTF, + chainId: 1, + governance: GOVERNANCE, + timelock: TIMELOCK, + version: "5.0.0", + tradeAllowlist: { enabled: true }, + }), + ).rejects.toThrow("setTradeAllowlistEnabled is not supported by Index DTF 5.0.0"); + await expect( + buildIndexDtfSettingsProposal({} as never, { + address: DTF, + chainId: 1, + governance: GOVERNANCE, + timelock: TIMELOCK, + version: "6.0.0", + selfFee: 101, + }), + ).rejects.toThrow("selfFee"); + await expect( + buildIndexDtfSettingsProposal({} as never, { + address: DTF, + chainId: 1, + governance: GOVERNANCE, + dtf: createDtfContext(), + version: "5.0.0", + immutableFeeRecipients: [], + revenueDistribution: { platformFee: 20, governanceShare: 0, deployerShare: 80, additionalRecipients: [] }, + }), + ).rejects.toThrow("immutableFeeRecipients is not supported by Index DTF 5.0.0"); + await expect( + buildIndexDtfSettingsProposal({} as never, { + address: DTF, + chainId: 1, + governance: GOVERNANCE, + timelock: TIMELOCK, + version: "6.0.0", + tradeAllowlist: { add: [] }, + }), + ).rejects.toThrow("at least one call"); + }); + it("encodes repeating revenue recipient portions", async () => { const proposal = await buildIndexDtfSettingsProposal({} as never, { address: DTF, diff --git a/packages/sdk/src/index-dtf/governance/propose/settings.ts b/packages/sdk/src/index-dtf/governance/propose/settings.ts index 32be913..11bde47 100644 --- a/packages/sdk/src/index-dtf/governance/propose/settings.ts +++ b/packages/sdk/src/index-dtf/governance/propose/settings.ts @@ -15,4 +15,5 @@ export type { IndexDtfGovernanceChanges, IndexDtfRevenueDistributionInput, IndexDtfRevenueRecipientInput, + IndexDtfTradeAllowlistChanges, } from "@/index-dtf/governance/propose/settings-types"; diff --git a/packages/sdk/src/index-dtf/governance/propose/upgrade-v6.test.ts b/packages/sdk/src/index-dtf/governance/propose/upgrade-v6.test.ts new file mode 100644 index 0000000..d6c7047 --- /dev/null +++ b/packages/sdk/src/index-dtf/governance/propose/upgrade-v6.test.ts @@ -0,0 +1,203 @@ +import { zeroAddress } from "viem"; +import { describe, expect, it, vi } from "vitest"; + +import type { DtfClient } from "@/client"; +import type { IndexDtf } from "@/types/index-dtf"; + +import { + buildIndexDtfUpgradeToV6Calls, + buildIndexDtfUpgradeToV6Proposal, + INDEX_DTF_START_REBALANCE_SELECTOR, +} from "@/index-dtf/governance/propose/upgrade-v6"; +import { SdkError } from "@/lib/errors"; + +// Recorded by the mainnet Anvil sandbox (index-subgraph/.fork/fixture.json, spell commit 33c31569). +const SPELL = "0x6f0921A2F9c7da5F579f0Fc83873E52f6352B994"; +const OPTIMISTIC = { + folio: "0x512A93643Ae35F895Ec02BEE94670aF77c9021BE", + proxyAdmin: "0xD67a34848dB699670052B8a81220C6c71d371150", + selectorRegistry: "0x3dAFc73Ca466fd7A392535Fc80bd66C5792403c2", + governor: "0xc8454a2E7966E1602346d494b1e2aB526C819B1C", + timelock: "0xC64761bCE2aB9464AcC128414ee3af5Bc6837158", + calldatas: [ + "0x39535e96000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000512a93643ae35f895ec02bee94670af77c9021be00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000001c1e54b8900000000000000000000000000000000000000000000000000000000", + "0x3bb9e672000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000512a93643ae35f895ec02bee94670af77c9021be00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000001207c8eed00000000000000000000000000000000000000000000000000000000", + "0xf2fde38b0000000000000000000000006f0921a2f9c7da5f579f0fc83873e52f6352b994", + "0x2aa6b211000000000000000000000000512a93643ae35f895ec02bee94670af77c9021be000000000000000000000000d67a34848db699670052b8a81220c6c71d3711500000000000000000000000003dafc73ca466fd7a392535fc80bd66c5792403c2", + ], +} as const; +const LEGACY = { + folio: "0x6a473eB2FfAf3EFD14E136C72Ee94C2c6a7c3087", + proxyAdmin: "0x22FaE17D5832f661A6d6d4bdA7222B7A1b55aCF6", + governor: "0xb3a1CFF3BB783382c0D31F5AB5077e7D1F4Ed339", + timelock: "0x72d25CEe4888Eb26599Dcb20FB71FF2FB747DAa4", + calldatas: [ + "0xf2fde38b0000000000000000000000006f0921a2f9c7da5f579f0fc83873e52f6352b994", + "0x2aa6b2110000000000000000000000006a473eb2ffaf3efd14e136c72ee94c2c6a7c308700000000000000000000000022fae17d5832f661a6d6d4bda7222b7a1b55acf60000000000000000000000000000000000000000000000000000000000000000", + ], +} as const; + +function dtfContext(input: { + readonly proxyAdmin: string; + readonly governor: string; + readonly timelock: string; + readonly selectorRegistry?: string; +}): IndexDtf { + return { + roles: { deployment: { proxyAdmin: input.proxyAdmin } }, + governance: { + admin: { + primary: { + type: "governance", + address: input.governor, + governance: { + address: input.governor, + isOptimistic: input.selectorRegistry !== undefined, + ...(input.selectorRegistry ? { optimistic: { selectorRegistry: input.selectorRegistry } } : {}), + timelock: { address: input.timelock }, + }, + }, + }, + }, + } as unknown as IndexDtf; +} + +function clientWithVersion(version: string): DtfClient { + return { + viem: { getPublicClient: vi.fn(() => ({ readContract: vi.fn(async () => version) })) }, + } as unknown as DtfClient; +} + +describe("Folio 6.0 upgrade proposal", () => { + it("derives the startRebalance selectors from the two ABIs", () => { + expect(INDEX_DTF_START_REBALANCE_SELECTOR).toEqual({ "5.0.0": "0x207c8eed", "6.0.0": "0xc1e54b89" }); + }); + + it("reproduces the sandbox's optimistic upgrade proposal byte for byte", () => { + const calls = buildIndexDtfUpgradeToV6Calls({ + chainId: 1, + address: OPTIMISTIC.folio, + proxyAdmin: OPTIMISTIC.proxyAdmin, + spell: SPELL, + selectorRegistry: OPTIMISTIC.selectorRegistry, + }); + + expect(calls.map((call) => call.to)).toEqual([ + OPTIMISTIC.selectorRegistry, + OPTIMISTIC.selectorRegistry, + OPTIMISTIC.proxyAdmin, + SPELL, + ]); + expect(calls.map((call) => call.data)).toEqual([...OPTIMISTIC.calldatas]); + expect(calls.every((call) => call.value === 0n && call.chainId === 1)).toBe(true); + }); + + it("reproduces the sandbox's standard-governance upgrade proposal byte for byte, zero registry included", () => { + const base = { chainId: 1, address: LEGACY.folio, proxyAdmin: LEGACY.proxyAdmin, spell: SPELL } as const; + const calls = buildIndexDtfUpgradeToV6Calls(base); + + expect(calls.map((call) => call.to)).toEqual([LEGACY.proxyAdmin, SPELL]); + expect(calls.map((call) => call.data)).toEqual([...LEGACY.calldatas]); + expect(calls.every((call) => call.value === 0n && call.chainId === 1)).toBe(true); + expect(buildIndexDtfUpgradeToV6Calls({ ...base, selectorRegistry: zeroAddress }).map((call) => call.data)).toEqual([ + ...LEGACY.calldatas, + ]); + }); + + it("builds the proposal from an injected optimistic admin governance and rejects non-5.0.0 DTFs", async () => { + const dtf = dtfContext(OPTIMISTIC); + const proposal = await buildIndexDtfUpgradeToV6Proposal(clientWithVersion("5.0.0"), { + address: OPTIMISTIC.folio, + chainId: 1, + spell: SPELL, + dtf, + }); + + expect(proposal).toMatchObject({ + governance: OPTIMISTIC.governor, + timelock: OPTIMISTIC.timelock, + description: "Upgrade to Folio 6.0.0", + }); + expect(proposal.targets).toEqual([ + OPTIMISTIC.selectorRegistry, + OPTIMISTIC.selectorRegistry, + OPTIMISTIC.proxyAdmin, + SPELL, + ]); + expect(proposal.calldatas).toEqual([...OPTIMISTIC.calldatas]); + + const rejection = buildIndexDtfUpgradeToV6Proposal(clientWithVersion("6.0.0"), { + address: OPTIMISTIC.folio, + chainId: 1, + spell: SPELL, + dtf, + }); + await expect(rejection).rejects.toBeInstanceOf(SdkError); + await expect(rejection).rejects.toMatchObject({ code: "INVALID_INPUT", meta: { version: "6.0.0" } }); + }); + + it("builds the standard proposal without any read when every address is explicit", async () => { + const client = { viem: { getPublicClient: vi.fn() }, subgraph: vi.fn() } as unknown as DtfClient; + const proposal = await buildIndexDtfUpgradeToV6Proposal(client, { + address: LEGACY.folio, + chainId: 1, + spell: SPELL, + version: "5.0.0", + proxyAdmin: LEGACY.proxyAdmin, + governance: LEGACY.governor, + timelock: LEGACY.timelock, + description: "Upgrade", + }); + + expect(proposal).toMatchObject({ governance: LEGACY.governor, timelock: LEGACY.timelock, description: "Upgrade" }); + expect(proposal.calldatas).toEqual([...LEGACY.calldatas]); + expect(client.viem.getPublicClient).not.toHaveBeenCalled(); + }); + + it("treats an explicit governor as standard unless its selector registry is passed", async () => { + const dtf = dtfContext(OPTIMISTIC); + const standard = await buildIndexDtfUpgradeToV6Proposal(clientWithVersion("5.0.0"), { + address: OPTIMISTIC.folio, + chainId: 1, + spell: SPELL, + dtf, + governance: LEGACY.governor, + timelock: LEGACY.timelock, + }); + expect(standard.calldatas).toHaveLength(2); + + const optimistic = await buildIndexDtfUpgradeToV6Proposal(clientWithVersion("5.0.0"), { + address: OPTIMISTIC.folio, + chainId: 1, + spell: SPELL, + dtf, + governance: OPTIMISTIC.governor, + timelock: OPTIMISTIC.timelock, + selectorRegistry: OPTIMISTIC.selectorRegistry, + }); + expect(optimistic.calldatas).toEqual([...OPTIMISTIC.calldatas]); + }); + + it("requires a selector registry for an indexed optimistic admin and a governance for address authorities", async () => { + const missingRegistry = buildIndexDtfUpgradeToV6Proposal(clientWithVersion("5.0.0"), { + address: OPTIMISTIC.folio, + chainId: 1, + spell: SPELL, + dtf: dtfContext({ ...OPTIMISTIC, selectorRegistry: zeroAddress }), + }); + await expect(missingRegistry).rejects.toThrow("selectorRegistry is required"); + + const addressAuthority = { + roles: { deployment: { proxyAdmin: LEGACY.proxyAdmin } }, + governance: { admin: { primary: { type: "address", address: LEGACY.governor } } }, + } as unknown as IndexDtf; + await expect( + buildIndexDtfUpgradeToV6Proposal(clientWithVersion("5.0.0"), { + address: LEGACY.folio, + chainId: 1, + spell: SPELL, + dtf: addressAuthority, + }), + ).rejects.toThrow("governance is required"); + }); +}); diff --git a/packages/sdk/src/index-dtf/governance/propose/upgrade-v6.ts b/packages/sdk/src/index-dtf/governance/propose/upgrade-v6.ts new file mode 100644 index 0000000..057c0f5 --- /dev/null +++ b/packages/sdk/src/index-dtf/governance/propose/upgrade-v6.ts @@ -0,0 +1,169 @@ +import { getAbiItem, getAddress, toFunctionSelector, zeroAddress, type Address, type Hex } from "viem"; + +import type { DtfClient } from "@/client"; +import type { SupportedChainId } from "@/config"; +import type { BuiltIndexDtfProposal } from "@/index-dtf/governance/propose/settings-types"; +import type { DtfParams } from "@/types/common"; +import type { IndexDtfCall } from "@/types/governance"; +import type { IndexDtf } from "@/types/index-dtf"; + +import { dtfAdminProposalAbi } from "@/index-dtf/abis/dtf-admin-proposal"; +import { upgradeSpell600Abi } from "@/index-dtf/abis/upgrade-spell-6-0-0"; +import { getVersion } from "@/index-dtf/dtf/index"; +import { + buildCallPayload, + buildSettingsProposal, + getAuthorityGovernance, + getDtfIfNeeded, +} from "@/index-dtf/governance/propose/settings-shared"; +import { + prepareSelectorRegistryRegisterSelectors, + prepareSelectorRegistryUnregisterSelectors, +} from "@/index-dtf/governance/selector-registry"; +import { getIndexDtfWriteAbi } from "@/index-dtf/write-version"; +import { prepareContractCall } from "@/lib/contract-call"; +import { SdkError } from "@/lib/errors"; + +/** `startRebalance` selectors: the optimistic selector registry must swap v5 for v6 in the same proposal. */ +export const INDEX_DTF_START_REBALANCE_SELECTOR = { + "5.0.0": toFunctionSelector(getAbiItem({ abi: getIndexDtfWriteAbi("5.0.0"), name: "startRebalance" })), + "6.0.0": toFunctionSelector(getAbiItem({ abi: getIndexDtfWriteAbi("6.0.0"), name: "startRebalance" })), +} as const satisfies Record<"5.0.0" | "6.0.0", Hex>; + +export type BuildIndexDtfUpgradeToV6CallsParams = { + readonly chainId: SupportedChainId; + readonly address: Address; + readonly proxyAdmin: Address; + /** The deployed `UpgradeSpell_6_0_0` (`INDEX_DTF_UPGRADE_SPELL_6_0_0_ADDRESS`). */ + readonly spell: Address; + /** The optimistic governor's selector registry; omit (or pass the zero address) for standard governance. */ + readonly selectorRegistry?: Address; +}; + +export type BuildIndexDtfUpgradeToV6ProposalParams = DtfParams & { + readonly spell: Address; + readonly description?: string; + /** Skip the subgraph read when the caller already holds the DTF. */ + readonly dtf?: IndexDtf; + readonly version?: string; + readonly proxyAdmin?: Address; + /** Overriding the governor is identity-only: pass `selectorRegistry` too when it is optimistic. */ + readonly governance?: Address; + readonly timelock?: Address; + readonly selectorRegistry?: Address; +}; + +/** + * The exact call sequence the 5.0.0 → 6.0.0 spell needs from the admin timelock: hand the ProxyAdmin to the + * spell, then cast. An optimistic governor first swaps the registered `startRebalance` selector, because the + * v6 signature differs and the registry gates optimistic proposals by selector. + */ +export function buildIndexDtfUpgradeToV6Calls(params: BuildIndexDtfUpgradeToV6CallsParams): IndexDtfCall[] { + const folio = getAddress(params.address); + const proxyAdmin = getAddress(params.proxyAdmin); + const spell = getAddress(params.spell); + const selectorRegistry = resolveSelectorRegistry(params.selectorRegistry); + const calls: IndexDtfCall[] = []; + + if (selectorRegistry !== zeroAddress) { + calls.push( + prepareSelectorRegistryRegisterSelectors({ + chainId: params.chainId, + registry: selectorRegistry, + selectorData: [{ target: folio, selectors: [INDEX_DTF_START_REBALANCE_SELECTOR["6.0.0"]] }], + }), + prepareSelectorRegistryUnregisterSelectors({ + chainId: params.chainId, + registry: selectorRegistry, + selectorData: [{ target: folio, selectors: [INDEX_DTF_START_REBALANCE_SELECTOR["5.0.0"]] }], + }), + ); + } + + calls.push( + prepareContractCall({ + chainId: params.chainId, + address: proxyAdmin, + abi: dtfAdminProposalAbi, + functionName: "transferOwnership", + args: [spell] as const, + }), + prepareContractCall({ + chainId: params.chainId, + address: spell, + abi: upgradeSpell600Abi, + functionName: "cast", + args: [folio, proxyAdmin, selectorRegistry] as const, + }), + ); + + return calls; +} + +/** + * Builds the upgrade proposal against the DTF's admin governance; only a 5.0.0 DTF can be upgraded. It is a + * standard proposal even for an optimistic governor: the registry calls are not allowlisted selectors. + */ +export async function buildIndexDtfUpgradeToV6Proposal( + client: DtfClient, + params: BuildIndexDtfUpgradeToV6ProposalParams, +): Promise { + const needsDtf = params.proxyAdmin === undefined || params.governance === undefined || params.timelock === undefined; + const [version, dtf] = await Promise.all([ + params.version ?? getVersion(client, params), + getDtfIfNeeded(client, params, needsDtf), + ]); + if (version !== "5.0.0") { + throw new SdkError({ + code: "INVALID_INPUT", + message: `Only Index DTF 5.0.0 can be upgraded to 6.0.0; this DTF is ${version}`, + meta: { version }, + }); + } + + const adminGovernance = getAuthorityGovernance(dtf?.governance.admin.primary); + const governance = params.governance ?? adminGovernance?.address; + const timelock = params.timelock ?? adminGovernance?.timelock.address; + // An explicit governor says nothing about its kind; only the indexed admin governance can imply optimistic. + const selectorRegistry = + params.governance !== undefined + ? params.selectorRegistry + : (params.selectorRegistry ?? + (adminGovernance?.isOptimistic ? adminGovernance.optimistic?.selectorRegistry : undefined)); + + if ( + params.governance === undefined && + adminGovernance?.isOptimistic && + resolveSelectorRegistry(selectorRegistry) === zeroAddress + ) { + throw new SdkError({ + code: "INVALID_INPUT", + message: "selectorRegistry is required to upgrade an optimistically governed Index DTF", + meta: { governance }, + }); + } + + const calls = buildIndexDtfUpgradeToV6Calls({ + chainId: params.chainId, + address: params.address, + proxyAdmin: params.proxyAdmin ?? requireProxyAdmin(dtf), + spell: params.spell, + ...(selectorRegistry ? { selectorRegistry } : {}), + }); + + return buildSettingsProposal({ + ...buildCallPayload({ governance, timelock, calls }), + description: params.description ?? "Upgrade to Folio 6.0.0", + }); +} + +function resolveSelectorRegistry(registry: Address | undefined): Address { + return registry === undefined ? zeroAddress : getAddress(registry); +} + +function requireProxyAdmin(dtf: IndexDtf | undefined): Address { + if (!dtf) { + throw new SdkError({ code: "INVALID_INPUT", message: "proxyAdmin is required to build an upgrade proposal" }); + } + return dtf.roles.deployment.proxyAdmin; +} diff --git a/packages/sdk/src/index-dtf/governance/ref.ts b/packages/sdk/src/index-dtf/governance/ref.ts index 3283d7f..bed2d76 100644 --- a/packages/sdk/src/index-dtf/governance/ref.ts +++ b/packages/sdk/src/index-dtf/governance/ref.ts @@ -4,6 +4,8 @@ import type { BuildIndexDtfBasketSettingsProposalParams, BuildIndexDtfDaoSettingsProposalParams, BuildIndexDtfSettingsProposalParams, + BuildIndexDtfUpgradeToV6CallsParams, + BuildIndexDtfUpgradeToV6ProposalParams, } from "@/index-dtf/governance/propose/index"; import type { DtfParams } from "@/types/common"; import type { @@ -47,6 +49,8 @@ import { buildIndexDtfBasketSettingsProposal, buildIndexDtfDaoSettingsProposal, buildIndexDtfSettingsProposal, + buildIndexDtfUpgradeToV6Calls, + buildIndexDtfUpgradeToV6Proposal, } from "@/index-dtf/governance/propose/index"; /** Creates address-bound Index DTF governance methods for the flat ref. */ @@ -98,6 +102,10 @@ export function createIndexDtfGovernanceRef(client: DtfClient, params: DtfParams }), buildSettingsProposal: (proposal: Omit) => buildIndexDtfSettingsProposal(client, { ...proposal, address, chainId }), + buildUpgradeToV6Calls: (params: Omit) => + buildIndexDtfUpgradeToV6Calls({ ...params, address, chainId }), + buildUpgradeToV6Proposal: (params: Omit) => + buildIndexDtfUpgradeToV6Proposal(client, { ...params, address, chainId }), prepareVote: (call: Omit[0], "chainId">) => prepareIndexDtfVote({ ...call, chainId }), prepareQueueProposal: (call: Omit[0], "chainId">) => diff --git a/packages/sdk/src/index-dtf/governance/spell-decoding.test.ts b/packages/sdk/src/index-dtf/governance/spell-decoding.test.ts new file mode 100644 index 0000000..c7e2d32 --- /dev/null +++ b/packages/sdk/src/index-dtf/governance/spell-decoding.test.ts @@ -0,0 +1,104 @@ +import { encodeFunctionData, zeroAddress } from "viem"; +import { describe, expect, it } from "vitest"; + +import { + governanceSpell09182026Abi, + INDEX_DTF_GOVERNANCE_SPELL_09_18_2026_ADDRESS, + ownableTransferOwnershipAbi, +} from "@/index-dtf/abis/governance-spell-09-18-2026"; +import { INDEX_DTF_UPGRADE_SPELL_6_0_0_ADDRESS } from "@/index-dtf/abis/upgrade-spell-6-0-0"; +import { buildProposalContractMap } from "@/index-dtf/governance/contract-map"; +import { decodeIndexDtfProposalCalldatas } from "@/index-dtf/governance/decoder"; +import { buildIndexDtfUpgradeToV6Calls } from "@/index-dtf/governance/propose/upgrade-v6"; + +const DTF = "0x0000000000000000000000000000000000000001"; +const PROXY_ADMIN = "0x0000000000000000000000000000000000000002"; +const ST_TOKEN = "0x0000000000000000000000000000000000000003"; +const REGISTRY = "0x00000000000000000000000000000000000000aa"; +const OLD_VAULT = "0x00000000000000000000000000000000000000bb"; + +const contractMap = () => + buildProposalContractMap({ + chainId: 8453, + dtf: { + address: DTF, + proxyAdmin: PROXY_ADMIN, + legacyAdminGovernance: [], + legacyTradingGovernance: [], + stakingToken: { address: ST_TOKEN, legacyGovernance: [] }, + }, + }); + +const decodeCalls = (calls: ReturnType) => + decodeIndexDtfProposalCalldatas({ + targets: calls.map((call) => call.contract.address), + calldatas: calls.map((call) => + encodeFunctionData({ + abi: call.contract.abi, + functionName: call.contract.functionName, + args: call.contract.args, + } as never), + ), + contractMap: contractMap(), + }); + +describe("spell proposal decoding", () => { + it("names every call of an optimistic 5.0.0 → 6.0.0 upgrade proposal", () => { + const decoded = decodeCalls( + buildIndexDtfUpgradeToV6Calls({ + chainId: 8453, + address: DTF, + proxyAdmin: PROXY_ADMIN, + spell: INDEX_DTF_UPGRADE_SPELL_6_0_0_ADDRESS[8453], + selectorRegistry: REGISTRY, + }), + ); + + expect(decoded.unknownCalls).toEqual([]); + expect(decoded.calls.map(({ contract, functionName }) => [contract, functionName])).toEqual([ + ["Selector Registry", "registerSelectors"], + ["Selector Registry", "unregisterSelectors"], + ["ProxyAdmin", "transferOwnership"], + ["Upgrade Spell 6.0.0", "cast"], + ]); + }); + + it("names both calls of a legacy 5.0.0 → 6.0.0 upgrade proposal", () => { + const decoded = decodeCalls( + buildIndexDtfUpgradeToV6Calls({ + chainId: 8453, + address: DTF, + proxyAdmin: PROXY_ADMIN, + spell: INDEX_DTF_UPGRADE_SPELL_6_0_0_ADDRESS[8453], + }), + ); + + expect(decoded.calls.map(({ contract, functionName }) => [contract, functionName])).toEqual([ + ["ProxyAdmin", "transferOwnership"], + ["Upgrade Spell 6.0.0", "cast"], + ]); + expect(decoded.calls[1]?.params).toEqual([DTF, PROXY_ADMIN, zeroAddress]); + }); + + it("names the old vault hand-off and retirement of a vault retire proposal", () => { + const spell = INDEX_DTF_GOVERNANCE_SPELL_09_18_2026_ADDRESS[8453]; + const decoded = decodeIndexDtfProposalCalldatas({ + targets: [OLD_VAULT, spell], + calldatas: [ + encodeFunctionData({ abi: ownableTransferOwnershipAbi, functionName: "transferOwnership", args: [spell] }), + encodeFunctionData({ + abi: governanceSpell09182026Abi, + functionName: "retireOldStakingVault", + args: [OLD_VAULT], + }), + ], + contractMap: contractMap(), + }); + + expect(decoded.unknownCalls).toEqual([]); + expect(decoded.calls.map(({ contract, functionName }) => [contract, functionName])).toEqual([ + ["Ownable Contract", "transferOwnership"], + ["Governance Spell 09_18_2026", "retireOldStakingVault"], + ]); + }); +}); diff --git a/packages/sdk/src/index-dtf/governance/utils.ts b/packages/sdk/src/index-dtf/governance/utils.ts index fe2e6e9..40e3339 100644 --- a/packages/sdk/src/index-dtf/governance/utils.ts +++ b/packages/sdk/src/index-dtf/governance/utils.ts @@ -19,6 +19,12 @@ export type DtfGovernanceAddressContext = { readonly ownerGovernance?: { readonly id: string } | null; readonly tradingGovernance?: { readonly id: string } | null; readonly legacyAdmins: readonly string[]; + readonly legacyAdminGovernances?: + | readonly { + readonly id: string; + readonly token: { readonly governance?: { readonly id: string } | null }; + }[] + | null; readonly legacyAuctionApprovers: readonly string[]; readonly stToken: { readonly governance?: { readonly id: string } | null; @@ -62,6 +68,9 @@ export function getProposalGovernanceAddresses(dtf: IndexDtf): readonly Address[ ...dtf.governance.all.flatMap((authority) => (authority.type === "governance" ? [authority.address] : [])), ...(dtf.governance.voteLock?.type === "governance" ? [dtf.governance.voteLock.address] : []), ...dtf.roles.admin.legacy, + ...dtf.roles.admin.legacyGovernances.flatMap(({ governance, voteLockGovernance }) => + voteLockGovernance ? [governance, voteLockGovernance] : [governance], + ), ...dtf.roles.rebalance.legacyAuctionApprovers, ...(dtf.voteLockVault?.legacyGovernance ?? []), ]); @@ -73,6 +82,9 @@ export function getDtfProposalGovernanceIds(dtf: DtfGovernanceAddressContext): r ...(dtf.tradingGovernance ? [dtf.tradingGovernance.id] : []), ...(dtf.stToken.governance ? [dtf.stToken.governance.id] : []), ...dtf.legacyAdmins, + ...(dtf.legacyAdminGovernances ?? []).flatMap(({ id, token }) => + token.governance ? [id, token.governance.id] : [id], + ), ...dtf.legacyAuctionApprovers, ...dtf.stToken.legacyGovernance, ]); diff --git a/packages/sdk/src/index-dtf/governance/vote-lock-dependents.ts b/packages/sdk/src/index-dtf/governance/vote-lock-dependents.ts new file mode 100644 index 0000000..e17a7a9 --- /dev/null +++ b/packages/sdk/src/index-dtf/governance/vote-lock-dependents.ts @@ -0,0 +1,62 @@ +import { getAddress, keccak256, toHex, zeroHash, type Address } from "viem"; + +import type { DtfClient } from "@/client"; +import type { GetIndexDtfVoteLockDependentsParams, IndexDtfVoteLockDependent } from "@/types/governance"; + +import { dtfIndexAbi } from "@/index-dtf/abis/dtf-index-abi"; +import { GetIndexDtfVoteLockDependentsDocument } from "@/index-dtf/subgraph/dtf.generated"; +import { sameAddress } from "@/lib/utils"; + +const REBALANCE_MANAGER = keccak256(toHex("REBALANCE_MANAGER")); + +type GovernanceRef = + | { readonly token: { readonly id: string }; readonly timelock: { readonly id: string } } + | null + | undefined; + +/** + * DTFs governed through `voteLock` by their owner (admin role) or trading (REBALANCE_MANAGER) governance, + * with the on-chain role as the source of truth: the subgraph only drops a DTF after indexing its migration. + */ +export async function getVoteLockDependents( + client: DtfClient, + params: GetIndexDtfVoteLockDependentsParams, +): Promise { + const { dtfs } = await client.subgraph.queryIndex({ + chainId: params.chainId, + query: GetIndexDtfVoteLockDependentsDocument, + variables: { voteLock: params.voteLock.toLowerCase() }, + }); + + const checks = dtfs.flatMap((dtf) => [ + ...roleCheck(dtf.id, dtf.ownerGovernance, zeroHash, params.voteLock), + ...roleCheck(dtf.id, dtf.tradingGovernance, REBALANCE_MANAGER, params.voteLock), + ]); + const roles = + checks.length === 0 + ? [] + : await client.viem.getPublicClient(params.chainId).multicall({ + allowFailure: false, + contracts: checks.map(({ dtf, role, timelock }) => ({ + address: dtf, + abi: dtfIndexAbi, + functionName: "hasRole" as const, + args: [role, timelock] as const, + })), + }); + + return dtfs.map((dtf) => ({ + address: getAddress(dtf.id), + symbol: dtf.token.symbol, + name: dtf.token.name, + governedByVoteLock: checks.some((check, index) => sameAddress(check.dtf, dtf.id) && roles[index] === true), + })); +} + +function roleCheck(dtf: string, governance: GovernanceRef, role: `0x${string}`, voteLock: Address) { + if (!governance || !sameAddress(governance.token.id, voteLock)) { + return []; + } + + return [{ dtf: getAddress(dtf), role, timelock: getAddress(governance.timelock.id) }]; +} diff --git a/packages/sdk/src/index-dtf/governance/vote-lock-migration.test.ts b/packages/sdk/src/index-dtf/governance/vote-lock-migration.test.ts new file mode 100644 index 0000000..b5c6b92 --- /dev/null +++ b/packages/sdk/src/index-dtf/governance/vote-lock-migration.test.ts @@ -0,0 +1,107 @@ +import { getAddress } from "viem"; +import { describe, expect, it, vi } from "vitest"; + +import type { DtfClient } from "@/client"; +import type { IndexDtf } from "@/types/index-dtf"; + +import { getLegacyVoteLocks } from "@/index-dtf/governance/legacy-vote-lock"; +import { getDtfProposalGovernanceIds, getProposalGovernanceAddresses } from "@/index-dtf/governance/utils"; +import { getVoteLockDependents } from "@/index-dtf/governance/vote-lock-dependents"; + +const SINGLETON = "0x2F0D6538807a77d4AdDCd4b4DAf214Ea2E818E3D"; +const OLD_VAULT = "0x45A96cD0E4D89a41eebF3cC4204B00B1cf1582FA"; +const OLD_OWNER_GOVERNOR = "0x719eDEd05c7a6468E44AcFBBD19b2DF2EED7759E"; +const OLD_VAULT_DAO = getAddress("0x2dee428bd8131faa4288750d707de6f3901afe3c"); +const NEW_GOVERNOR = "0x00000000000000000000000000000000000000aa"; +const MIGRATED = "0x4dA9A0f397dB1397902070f93a4D6ddBC0E0E6e8"; +const PENDING = "0x00000000000000000000000000000000000000b2"; + +const migratedDtf = { + chainId: 8453, + roles: { + admin: { + legacy: [], + legacyGovernances: [{ governance: OLD_OWNER_GOVERNOR, voteLock: OLD_VAULT, voteLockGovernance: OLD_VAULT_DAO }], + }, + rebalance: { legacyAuctionApprovers: [] }, + }, + governance: { all: [], voteLock: undefined }, + voteLockVault: { token: { address: SINGLETON }, legacyGovernance: [] }, +} as unknown as IndexDtf; + +describe("vote-lock migration data", () => { + it("lists the vault a replaced Folio admin governor voted with as a legacy vote lock", async () => { + const readContract = vi.fn(); + const client = { viem: { readContract } } as unknown as DtfClient; + + expect(await getLegacyVoteLocks(client, { dtf: migratedDtf })).toEqual([OLD_VAULT]); + expect(readContract).not.toHaveBeenCalled(); + }); + + it("keeps the replaced admin governor and its vault DAO in the proposal governance set", () => { + expect(getProposalGovernanceAddresses(migratedDtf)).toEqual([OLD_OWNER_GOVERNOR, OLD_VAULT_DAO]); + expect( + getDtfProposalGovernanceIds({ + ownerGovernance: { id: NEW_GOVERNOR }, + legacyAdmins: [], + legacyAdminGovernances: [{ id: OLD_OWNER_GOVERNOR, token: { governance: { id: OLD_VAULT_DAO } } }], + legacyAuctionApprovers: [], + stToken: { governance: null, legacyGovernance: [] }, + }), + ).toEqual([NEW_GOVERNOR, OLD_OWNER_GOVERNOR.toLowerCase(), OLD_VAULT_DAO.toLowerCase()]); + }); + + it("keeps a DTF pending while either its owner or its trading timelock still holds the role through the vault", async () => { + const vault = OLD_VAULT.toLowerCase(); + const other = "0x00000000000000000000000000000000000000ff"; + const gov = (token: string, timelock: string) => ({ token: { id: token }, timelock: { id: timelock } }); + const queryIndex = vi.fn().mockResolvedValue({ + dtfs: [ + { + id: MIGRATED.toLowerCase(), + token: { symbol: "LCAP", name: "LCAP" }, + ownerGovernance: gov(vault, "0x0000000000000000000000000000000000000c01"), + tradingGovernance: gov(vault, "0x0000000000000000000000000000000000000c02"), + }, + { + id: PENDING, + token: { symbol: "TRADE", name: "Trading only" }, + ownerGovernance: gov(other, "0x0000000000000000000000000000000000000c03"), + tradingGovernance: gov(vault, "0x0000000000000000000000000000000000000c04"), + }, + { + id: "0x00000000000000000000000000000000000000c3", + token: { symbol: "NOGOV", name: "No governance" }, + ownerGovernance: null, + tradingGovernance: null, + }, + ], + }); + const multicall = vi.fn().mockResolvedValue([false, false, true]); + const client = { + subgraph: { queryIndex }, + viem: { getPublicClient: () => ({ multicall }) }, + } as unknown as DtfClient; + + const dependents = await getVoteLockDependents(client, { chainId: 8453, voteLock: OLD_VAULT }); + + expect(queryIndex).toHaveBeenCalledWith(expect.objectContaining({ variables: { voteLock: vault } })); + expect(multicall.mock.calls[0]?.[0].contracts).toHaveLength(3); + expect(dependents.map(({ symbol, governedByVoteLock }) => [symbol, governedByVoteLock])).toEqual([ + ["LCAP", false], + ["TRADE", true], + ["NOGOV", false], + ]); + }); + + it("skips the role reads when no DTF uses the vault", async () => { + const multicall = vi.fn(); + const client = { + subgraph: { queryIndex: vi.fn().mockResolvedValue({ dtfs: [] }) }, + viem: { getPublicClient: () => ({ multicall }) }, + } as unknown as DtfClient; + + expect(await getVoteLockDependents(client, { chainId: 8453, voteLock: OLD_VAULT })).toEqual([]); + expect(multicall).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/sdk/src/index-dtf/governance/voting.test.ts b/packages/sdk/src/index-dtf/governance/voting.test.ts index 75a637c..121f592 100644 --- a/packages/sdk/src/index-dtf/governance/voting.test.ts +++ b/packages/sdk/src/index-dtf/governance/voting.test.ts @@ -125,7 +125,15 @@ describe("Index DTF governance voting", () => { }); it("reads proposal voter state at the proposal snapshot timepoint", async () => { - const multicall = vi.fn().mockResolvedValueOnce([1_000_000n]).mockResolvedValueOnce([4000000000000000000n]); + // The governor counts a delegation checkpoint written at voteStart itself. + const multicall = vi.fn( + async ({ contracts }: { contracts: readonly { functionName: string; args?: readonly unknown[] }[] }) => + contracts.map(({ functionName, args }) => { + if (functionName === "clock") return 1_000_000n; + if (functionName === "getVotes") return args?.[1] === 999_900n ? 4000000000000000000n : 0n; + throw new Error(`Unexpected read: ${functionName}`); + }), + ); const client = { viem: { getPublicClient: vi.fn(() => ({ multicall })), @@ -165,7 +173,7 @@ describe("Index DTF governance voting", () => { contracts: expect.arrayContaining([ expect.objectContaining({ functionName: "getVotes", - args: ["0x0000000000000000000000000000000000000002", 999899n], + args: ["0x0000000000000000000000000000000000000002", 999900n], }), ]), }), @@ -179,38 +187,41 @@ describe("Index DTF governance voting", () => { }); }); - it("clamps standard proposal voter state reads before future snapshots", async () => { - const multicall = vi.fn().mockResolvedValueOnce([1_000_000n]).mockResolvedValueOnce([4000000000000000000n]); - const client = { - viem: { - getPublicClient: vi.fn(() => ({ multicall })), - }, - } as unknown as DtfClient; + it.each([1_000_000, 1_000_100])( + "clamps the standard snapshot %i at or after the current clock", + async (voteStart) => { + const multicall = vi.fn().mockResolvedValueOnce([1_000_000n]).mockResolvedValueOnce([4000000000000000000n]); + const client = { + viem: { + getPublicClient: vi.fn(() => ({ multicall })), + }, + } as unknown as DtfClient; - await getProposalVoterState(client, { - chainId: 1, - governance: "0x0000000000000000000000000000000000000001", - account: "0x0000000000000000000000000000000000000002", - proposal: { - id: "1", - voteStart: 1_000_100, - voteToken: "0x0000000000000000000000000000000000000003", - votes: [], - }, - }); + await getProposalVoterState(client, { + chainId: 1, + governance: "0x0000000000000000000000000000000000000001", + account: "0x0000000000000000000000000000000000000002", + proposal: { + id: "1", + voteStart, + voteToken: "0x0000000000000000000000000000000000000003", + votes: [], + }, + }); - expect(multicall).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - contracts: expect.arrayContaining([ - expect.objectContaining({ - functionName: "getVotes", - args: ["0x0000000000000000000000000000000000000002", 999999n], - }), - ]), - }), - ); - }); + expect(multicall).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + contracts: expect.arrayContaining([ + expect.objectContaining({ + functionName: "getVotes", + args: ["0x0000000000000000000000000000000000000002", 999999n], + }), + ]), + }), + ); + }, + ); it("reads optimistic proposal voter state from the vote token snapshot", async () => { const multicall = vi diff --git a/packages/sdk/src/index-dtf/governance/voting.ts b/packages/sdk/src/index-dtf/governance/voting.ts index 6796620..8da364f 100644 --- a/packages/sdk/src/index-dtf/governance/voting.ts +++ b/packages/sdk/src/index-dtf/governance/voting.ts @@ -334,7 +334,7 @@ async function readStandardProposalVotingPower( account: GetIndexDtfProposalVoterStateParams["account"], voteStart: number, ) { - const snapshotTimepoint = BigInt(Math.max(voteStart - 1, 0)); + const snapshotTimepoint = BigInt(voteStart); const publicClient = client.viem.getPublicClient(chainId); const [clock] = (await publicClient.multicall({ allowFailure: false, diff --git a/packages/sdk/src/index-dtf/index.ts b/packages/sdk/src/index-dtf/index.ts index 1532005..ea88201 100644 --- a/packages/sdk/src/index-dtf/index.ts +++ b/packages/sdk/src/index-dtf/index.ts @@ -58,6 +58,34 @@ export { prepareIndexDtfDistributeFees, } from "@/index-dtf/dtf/revenue"; export { getIndexDtfPlatformFee } from "@/index-dtf/dtf/platform-fee"; +export { + assertIndexDtfFeeRecipientTables, + INDEX_DTF_FEE_RECIPIENT_TOTAL, + INDEX_DTF_MAX_FEE_RECIPIENTS, + scaleIndexDtfFeeRecipients, + sortIndexDtfFeeRecipients, +} from "@/index-dtf/fee-recipients"; +export type { IndexDtfFeeRecipient } from "@/index-dtf/fee-recipients"; +export { + getIndexDtfImmutableFeeRecipients, + getIndexDtfIsTokenAllowlisted, + getIndexDtfMaxAuctionLength, + getIndexDtfSelfFee, + getIndexDtfTradeAllowlist, +} from "@/index-dtf/dtf/folio-v6"; +export type { GetIndexDtfIsTokenAllowlistedParams, IndexDtfTradeAllowlist } from "@/index-dtf/dtf/folio-v6"; +export { + getIndexDtfLatestVersion, + getIndexDtfVersionDeployment, + getIndexDtfVersionHash, + INDEX_DTF_VERSION_REGISTRY_ADDRESS, +} from "@/index-dtf/version-registry"; +export type { + GetIndexDtfLatestVersionParams, + GetIndexDtfVersionDeploymentParams, + IndexDtfLatestVersion, + IndexDtfVersionDeployment, +} from "@/index-dtf/version-registry"; export { getIndexDtfTransactions } from "@/index-dtf/dtf/transactions"; export { buildIndexDtfDeployFeeRecipients, @@ -66,7 +94,9 @@ export { extractIndexDtfDeployedStakingTokenAddress, generateIndexDtfDeploymentNonce, getIndexDtfDeployApprovalAmount, + getIndexDtfDeployerAddress, INDEX_DTF_DEPLOYER_ADDRESS, + INDEX_DTF_V6_DEPLOYER_ADDRESS, INDEX_DTF_GOVERNANCE_DEPLOYER_ADDRESS, prepareIndexDtfDeploy, prepareIndexDtfDeployAssetApproval, @@ -84,6 +114,7 @@ export { getTopVoters as getIndexDtfTopVoters } from "@/index-dtf/governance/top export { withProposalSummaryState as withIndexDtfProposalSummaryState } from "@/index-dtf/governance/proposals"; export { getGuardians as getIndexDtfGuardians } from "@/index-dtf/governance/guardians"; export { getLegacyVoteLocks as getIndexDtfLegacyVoteLocks } from "@/index-dtf/governance/legacy-vote-lock"; +export { getVoteLockDependents as getIndexDtfVoteLockDependents } from "@/index-dtf/governance/vote-lock-dependents"; export { daoFeeRegistryAbi } from "@/index-dtf/abis/dao-fee-registry"; export { dtfAdminProposalAbi } from "@/index-dtf/abis/dtf-admin-proposal"; export { dtfIndexAbi } from "@/index-dtf/abis/dtf-index-abi"; @@ -94,10 +125,14 @@ export { dtfIndexGovernanceAbi } from "@/index-dtf/abis/dtf-index-governance"; export { dtfIndexGovernanceOptimisticAbi } from "@/index-dtf/abis/dtf-index-governance-optimistic"; export { dtfIndexGovernanceProposalAbi } from "@/index-dtf/abis/dtf-index-governance-proposal"; export { dtfIndexProposalAbi, dtfIndexProposalAbiCatalog } from "@/index-dtf/abis/dtf-index-proposal"; +export { upgradeSpell600Abi } from "@/index-dtf/abis/upgrade-spell-6-0-0"; export type { DtfIndexProposalAbiCatalogEntry } from "@/index-dtf/abis/dtf-index-proposal"; export { dtfIndexStakingVaultAbi } from "@/index-dtf/abis/dtf-index-staking-vault"; export { dtfIndexStakingVaultOptimisticAbi } from "@/index-dtf/abis/dtf-index-staking-vault-optimistic"; +export { folioDeployerV6Abi } from "@/index-dtf/abis/folio-deployer-v6.generated"; +export { folioV6Abi } from "@/index-dtf/abis/folio-v6.generated"; export { folioArtifactAbi } from "@/index-dtf/abis/folio-artifact"; +export { folioVersionRegistryAbi } from "@/index-dtf/abis/folio-version-registry.generated"; export { indexDtfDeployerAbi } from "@/index-dtf/abis/deployer"; export { indexDtfGovernanceDeployerAbi } from "@/index-dtf/abis/governance-deployer"; export { indexDtfProposalDecoderAbi } from "@/index-dtf/abis/proposal-decoder"; @@ -106,6 +141,11 @@ export { selectorRegistryAbi } from "@/index-dtf/abis/selector-registry"; export { timelockAbi } from "@/index-dtf/abis/timelock"; export { unstakingManagerAbi } from "@/index-dtf/abis/unstaking-manager"; export { upgradeSpellProposalAbi } from "@/index-dtf/abis/upgrade-spell-proposal"; +export { INDEX_DTF_UPGRADE_SPELL_6_0_0_ADDRESS } from "@/index-dtf/abis/upgrade-spell-6-0-0"; +export { + governanceSpell09182026Abi, + INDEX_DTF_GOVERNANCE_SPELL_09_18_2026_ADDRESS, +} from "@/index-dtf/abis/governance-spell-09-18-2026"; export { getOptimisticGovernance as getIndexDtfOptimisticGovernance, getOptimisticProposalContext as getIndexDtfOptimisticProposalContext, @@ -149,6 +189,9 @@ export { buildIndexDtfBasketSettingsProposal, buildIndexDtfDaoSettingsProposal, buildIndexDtfSettingsProposal, + buildIndexDtfUpgradeToV6Calls, + buildIndexDtfUpgradeToV6Proposal, + INDEX_DTF_START_REBALANCE_SELECTOR, indexDtfBasketProposalSchema, indexDtfBasketProposalTokenSchema, indexDtfBasketSettingsProposalSchema, @@ -222,6 +265,7 @@ export { getRebalance as getIndexDtfRebalance, getRebalanceAuctions as getIndexDtfRebalanceAuctions, getRebalances as getIndexDtfRebalances, + getIndexDtfRebalanceNonce, INDEX_DTF_ONDO_LIMIT_BUFFER, prepareIndexDtfBid, prepareIndexDtfCloseAuction, @@ -230,6 +274,7 @@ export { prepareIndexDtfOpenAuction, prepareIndexDtfOpenAuctionUnrestricted, } from "@/index-dtf/rebalance/index"; +export type { PrepareIndexDtfEndRebalanceParams, PrepareIndexDtfOpenAuctionParams } from "@/index-dtf/rebalance/index"; export { getVoteLockDao as getIndexDtfVoteLockDao, getVoteLockDaos as getIndexDtfVoteLockDaos, diff --git a/packages/sdk/src/index-dtf/live-smoke.test.ts b/packages/sdk/src/index-dtf/live-smoke.test.ts index 7984cad..20aed77 100644 --- a/packages/sdk/src/index-dtf/live-smoke.test.ts +++ b/packages/sdk/src/index-dtf/live-smoke.test.ts @@ -12,6 +12,7 @@ import { dtfIndexGovernanceOptimisticAbi } from "@/index-dtf/abis/dtf-index-gove import { dtfIndexStakingVaultAbi } from "@/index-dtf/abis/dtf-index-staking-vault"; import { timelockAbi } from "@/index-dtf/abis/timelock"; import { DEFAULT_INDEX_DTF_DEPLOY_FLAGS } from "@/index-dtf/deploy/index"; +import { getIndexDtfVersionHash } from "@/index-dtf/version-registry"; import { prepareErc20Approval } from "@/lib/contract-call"; const BASE_CHAIN_ID = 8453; @@ -197,7 +198,7 @@ smokeDescribe("Index DTF live smoke", () => { }, 180_000); it(`${smokeCase.label}: validates optimistic governance expectations`, async () => { - const { dtf } = await getContext(smokeCase); + const { dtf, version } = await getContext(smokeCase); const optimisticGovernances = getGovernanceAuthorities(dtf).filter( (authority) => authority.governance.isOptimistic, ); @@ -254,7 +255,7 @@ smokeDescribe("Index DTF live smoke", () => { proposal: { governance: optimistic.governance, targets: [dtf.id], - calldatas: [sdk.index.prepareDistributeFees({ address: dtf.id, chainId: BASE_CHAIN_ID }).data], + calldatas: [sdk.index.prepareDistributeFees({ address: dtf.id, chainId: BASE_CHAIN_ID, version }).data], description: "Smoke optimistic proposal", }, }); @@ -263,8 +264,22 @@ smokeDescribe("Index DTF live smoke", () => { ).toBe("proposeOptimistic"); }, 180_000); + it(`${smokeCase.label}: reads the Folio version registry on every supported chain`, async () => { + for (const chainId of [1, BASE_CHAIN_ID, 56] as const) { + const latest = await sdk.index.getLatestVersion({ chainId }); + const deployment = await sdk.index.getVersionDeployment({ chainId, version: latest.version }); + + expect(latest.version, `chain ${chainId}`).toMatch(/^\d+\.\d+\.\d+$/); + expect(latest.versionHash).toBe(getIndexDtfVersionHash(latest.version)); + expect(latest.deployer).not.toBe(zeroAddress); + expect(deployment).toMatchObject({ deployer: latest.deployer, deprecated: latest.deprecated }); + expect(deployment?.implementation).not.toBe(zeroAddress); + await expect(sdk.index.getVersionDeployment({ chainId, version: "0.0.0" })).resolves.toBeNull(); + } + }, 120_000); + it(`${smokeCase.label}: validates revenue, issuance, vote-lock, and rebalance surfaces`, async () => { - const { dtf } = await getContext(smokeCase); + const { dtf, version } = await getContext(smokeCase); const stToken = expectVoteLock(dtf); const voteLockDaoPromise = smokeCase.expectsVoteLockDao ? sdk.index.getVoteLockDao({ address: smokeCase.address, chainId: BASE_CHAIN_ID }) @@ -330,7 +345,11 @@ smokeDescribe("Index DTF live smoke", () => { expect(Array.isArray(auctions)).toBe(true); } - const distributeFees = sdk.index.prepareDistributeFees({ address: smokeCase.address, chainId: BASE_CHAIN_ID }); + const distributeFees = sdk.index.prepareDistributeFees({ + address: smokeCase.address, + chainId: BASE_CHAIN_ID, + version, + }); const approval = sdk.index.prepareBasketApproval({ chainId: BASE_CHAIN_ID, address: smokeCase.address, @@ -339,6 +358,7 @@ smokeDescribe("Index DTF live smoke", () => { }); const mint = sdk.index.prepareMint({ chainId: BASE_CHAIN_ID, + version, address: smokeCase.address, shares: 1n, receiver: SMOKE_ACCOUNT, @@ -346,6 +366,7 @@ smokeDescribe("Index DTF live smoke", () => { }); const redeem = sdk.index.prepareRedeem({ chainId: BASE_CHAIN_ID, + version, address: smokeCase.address, shares: 1n, receiver: SMOKE_ACCOUNT, @@ -556,6 +577,7 @@ smokeDescribe("Index DTF live smoke", () => { } as const; const deploy = sdk.index.prepareDeploy({ chainId: BASE_CHAIN_ID, + version: "5.0.0", basicDetails: deployBasicDetails, additionalDetails: deployAdditionalDetails, flags: DEFAULT_INDEX_DTF_DEPLOY_FLAGS, @@ -564,6 +586,7 @@ smokeDescribe("Index DTF live smoke", () => { }); const governedDeploy = sdk.index.prepareDeployGoverned({ chainId: BASE_CHAIN_ID, + version: "5.0.0", stToken, basicDetails: deployBasicDetails, additionalDetails: deployAdditionalDetails, @@ -574,6 +597,7 @@ smokeDescribe("Index DTF live smoke", () => { }); const deployPlan = sdk.index.prepareDeployPlan({ chainId: BASE_CHAIN_ID, + version: "5.0.0", basicDetails: deployBasicDetails, additionalDetails: deployAdditionalDetails, flags: DEFAULT_INDEX_DTF_DEPLOY_FLAGS, diff --git a/packages/sdk/src/index-dtf/namespace.test.ts b/packages/sdk/src/index-dtf/namespace.test.ts index 8b5ea29..554a5d8 100644 --- a/packages/sdk/src/index-dtf/namespace.test.ts +++ b/packages/sdk/src/index-dtf/namespace.test.ts @@ -139,16 +139,18 @@ describe("Index DTF namespace", () => { const directMint = namespace.prepareMint({ address, chainId: 1, + version: "5.0.0", shares: 1n, receiver: account, minSharesOut: 1n, }); const refMint = dtf.prepareMint({ + version: "5.0.0", shares: 1n, receiver: account, minSharesOut: 1n, }); - const auction = dtf.prepareOpenAuctionUnrestricted({ rebalanceNonce: 4n }); + const auction = dtf.prepareOpenAuctionUnrestricted({ rebalanceNonce: 4n, version: "5.0.0" }); const plan = dtf.prepareVoteLockDepositPlan({ stToken: token, amount: 1n, diff --git a/packages/sdk/src/index-dtf/namespace.ts b/packages/sdk/src/index-dtf/namespace.ts index ebb44e0..f1effad 100644 --- a/packages/sdk/src/index-dtf/namespace.ts +++ b/packages/sdk/src/index-dtf/namespace.ts @@ -32,6 +32,13 @@ import { getIndexDtfStatuses, } from "@/index-dtf/dtf/discovery"; import { getIndexDtfExposure } from "@/index-dtf/dtf/exposure"; +import { + getIndexDtfImmutableFeeRecipients, + getIndexDtfIsTokenAllowlisted, + getIndexDtfMaxAuctionLength, + getIndexDtfSelfFee, + getIndexDtfTradeAllowlist, +} from "@/index-dtf/dtf/folio-v6"; import { getIndexDtfHolders } from "@/index-dtf/dtf/holders"; import { getBasket, @@ -74,6 +81,7 @@ import { getBidQuote, getLatestAuction, getIndexDtfCurrentRebalance, + getIndexDtfRebalanceNonce, getIndexDtfExceededOndoLegs, getIndexDtfMaxSafeRebalancePercent, getIndexDtfRebalanceLegSizes, @@ -95,6 +103,7 @@ import { type GetIndexDtfRebalancesParams, } from "@/index-dtf/rebalance/index"; import { createIndexDtfRef } from "@/index-dtf/ref"; +import { getIndexDtfLatestVersion, getIndexDtfVersionDeployment } from "@/index-dtf/version-registry"; import { createIndexDtfVoteLockNamespace } from "@/index-dtf/vote-lock/namespace"; /** Creates the direct Index DTF namespace for scripts, bots, CLI, and apps. */ @@ -130,6 +139,8 @@ export function createIndexDtfNamespace(client: DtfClient) { getIndexDtfAccountBalanceSnapshot(client, params), getTransactions: (params: Parameters[1]) => getIndexDtfTransactions(client, params), getBidsEnabled: (params: Parameters[1]) => getIndexDtfBidsEnabled(client, params), + getMaxAuctionLength: (params: Parameters[1]) => + getIndexDtfMaxAuctionLength(client, params), getRebalanceControl: (params: Parameters[1]) => getIndexDtfRebalanceControl(client, params), getPendingFeeShares: (params: Parameters[1]) => @@ -163,6 +174,19 @@ export function createIndexDtfNamespace(client: DtfClient) { getRebalanceAuctions: (params: Parameters[1]) => getRebalanceAuctions(client, params), getCurrentRebalance: (params: Parameters[1]) => getIndexDtfCurrentRebalance(client, params), + getRebalanceNonce: (params: Parameters[1]) => + getIndexDtfRebalanceNonce(client, params), + getSelfFee: (params: Parameters[1]) => getIndexDtfSelfFee(client, params), + getImmutableFeeRecipients: (params: Parameters[1]) => + getIndexDtfImmutableFeeRecipients(client, params), + getTradeAllowlist: (params: Parameters[1]) => + getIndexDtfTradeAllowlist(client, params), + getIsTokenAllowlisted: (params: Parameters[1]) => + getIndexDtfIsTokenAllowlisted(client, params), + getLatestVersion: (params: Parameters[1]) => + getIndexDtfLatestVersion(client, params), + getVersionDeployment: (params: Parameters[1]) => + getIndexDtfVersionDeployment(client, params), getRebalanceLiquidity: (params: Parameters[1]) => getIndexDtfRebalanceLiquidity(client, params), buildRebalanceLiquidityTrades: buildIndexDtfRebalanceLiquidityTrades, diff --git a/packages/sdk/src/index-dtf/rebalance/current.test.ts b/packages/sdk/src/index-dtf/rebalance/current.test.ts new file mode 100644 index 0000000..dbc5c83 --- /dev/null +++ b/packages/sdk/src/index-dtf/rebalance/current.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { DtfClient } from "@/client"; + +import { folioV6Abi } from "@/index-dtf/abis/folio-v6.generated"; +import { getIndexDtfRebalanceNonce } from "@/index-dtf/rebalance/current"; + +const DTF = "0x0000000000000000000000000000000000000001"; + +describe("getIndexDtfRebalanceNonce", () => { + it("reads getRebalanceNonce with the v6 ABI at the requested block", async () => { + const readContract = vi.fn(async () => 12n); + const client = { viem: { readContract } } as unknown as DtfClient; + + await expect( + getIndexDtfRebalanceNonce(client, { address: DTF, chainId: 56, blockNumber: 119_967_348n }), + ).resolves.toBe(12n); + expect(readContract).toHaveBeenCalledWith({ + address: DTF, + abi: folioV6Abi, + functionName: "getRebalanceNonce", + chainId: 56, + blockNumber: 119_967_348n, + }); + }); +}); diff --git a/packages/sdk/src/index-dtf/rebalance/current.ts b/packages/sdk/src/index-dtf/rebalance/current.ts index b732eaa..c07ed81 100644 --- a/packages/sdk/src/index-dtf/rebalance/current.ts +++ b/packages/sdk/src/index-dtf/rebalance/current.ts @@ -8,6 +8,7 @@ import type { DtfParams } from "@/types/common"; import type { IndexDtfTotalAssets } from "@/types/index-dtf"; import { dtfIndexAbi } from "@/index-dtf/abis/dtf-index-abi"; +import { folioV6Abi } from "@/index-dtf/abis/folio-v6.generated"; import { getTotalAssets, getTotalSupply } from "@/index-dtf/dtf/index"; export type IndexDtfCurrentRebalanceState = { @@ -16,6 +17,20 @@ export type IndexDtfCurrentRebalanceState = { readonly totalAssets: IndexDtfTotalAssets; }; +/** + * Reads Folio 6.0's `getRebalanceNonce()`: the nonce of the current rebalance, which the next + * `startRebalance` must exceed by exactly one. Reverts on v5 proxies. + */ +export async function getIndexDtfRebalanceNonce(client: DtfClient, params: DtfParams): Promise { + return client.viem.readContract({ + address: getAddress(params.address), + abi: folioV6Abi, + functionName: "getRebalanceNonce", + chainId: params.chainId, + blockNumber: params.blockNumber, + }); +} + /** * Reads live v5 rebalance state from RPC together with current supply/assets. */ diff --git a/packages/sdk/src/index-dtf/rebalance/execution.test.ts b/packages/sdk/src/index-dtf/rebalance/execution.test.ts index c8c2729..e023ec2 100644 --- a/packages/sdk/src/index-dtf/rebalance/execution.test.ts +++ b/packages/sdk/src/index-dtf/rebalance/execution.test.ts @@ -1,7 +1,10 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { decodeFunctionData } from "viem"; +import { describe, expect, it, vi } from "vitest"; import type { DtfClient } from "@/client"; +import { dtfIndexAbi } from "@/index-dtf/abis/dtf-index-abi"; +import { folioArtifactAbi } from "@/index-dtf/abis/folio-artifact"; import { getActiveAuction, getBidQuote, @@ -10,111 +13,173 @@ import { prepareIndexDtfCloseAuction, prepareIndexDtfEndRebalance, } from "@/index-dtf/rebalance/execution"; +import { SdkError } from "@/lib/errors"; const DTF = "0x0000000000000000000000000000000000000001"; const SELL_TOKEN = "0x0000000000000000000000000000000000000002"; const BUY_TOKEN = "0x0000000000000000000000000000000000000003"; -describe("Index DTF rebalance execution", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); +type MockChain = { + readonly nextAuctionId: bigint; + readonly auction: readonly [bigint, bigint, bigint]; + readonly rebalanceNonce: bigint; + readonly block: { readonly number: bigint; readonly timestamp: bigint }; +}; - it("reads the latest auction from RPC", async () => { - const readContract = vi.fn().mockResolvedValueOnce(3n).mockResolvedValueOnce([9n, 999_900n, 1_000_100n]); - const getBlock = vi.fn(async () => ({ timestamp: 1_000_000n })); - const client = { - viem: { - readContract, - getPublicClient: vi.fn(() => ({ getBlock })), - }, - } as unknown as DtfClient; - - const auction = await getLatestAuction(client, { - address: DTF, - chainId: 1, +// One fake chain: every read is served from the same state and records the +// block it was asked for, so same-block pinning is observable. +function createChainClient(chain: MockChain) { + const readContract = vi.fn( + async (request: { + chainId: number; + address: string; + functionName: string; + blockNumber?: bigint; + args?: readonly unknown[]; + }) => { + switch (request.functionName) { + case "nextAuctionId": + return chain.nextAuctionId; + case "auctions": + return chain.auction; + case "getRebalance": + return [ + chain.rebalanceNonce, + 0, + [], + { low: 0n, spot: 0n, high: 0n }, + { startedAt: 0n, restrictedUntil: 0n, availableUntil: 0n }, + true, + ]; + default: + throw new Error(`Unexpected read: ${request.functionName}`); + } + }, + ); + const getBlock = vi.fn(async () => ({ number: chain.block.number, timestamp: chain.block.timestamp })); + const client = { viem: { readContract, getPublicClient: vi.fn(() => ({ getBlock })) } } as unknown as DtfClient; + + return { client, readContract, getBlock }; +} + +const AUCTION: MockChain["auction"] = [9n, 999_900n, 1_000_100n]; + +describe("Index DTF rebalance execution", () => { + it("reads the latest auction from RPC pinned to the resolved head block", async () => { + const { client, readContract, getBlock } = createChainClient({ + nextAuctionId: 3n, + auction: AUCTION, + rebalanceNonce: 9n, + block: { number: 777n, timestamp: 1_000_000n }, }); - expect(readContract).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - functionName: "auctions", - args: [2n], - }), - ); - expect(getBlock).toHaveBeenCalledWith(); + const auction = await getLatestAuction(client, { address: DTF, chainId: 1 }); + + expect(getBlock).toHaveBeenCalledWith({}); + expect(readContract.mock.calls.map(([request]) => [request.functionName, request.blockNumber])).toEqual([ + ["nextAuctionId", 777n], + ["auctions", 777n], + ["getRebalance", 777n], + ]); expect(auction).toEqual({ auctionId: 2n, rebalanceNonce: 9n, + currentRebalanceNonce: 9n, startTime: 999_900n, endTime: 1_000_100n, + blockNumber: 777n, isActive: true, }); }); it("returns null when no auctions have opened", async () => { - const readContract = vi.fn(async () => 0n); - const client = { viem: { readContract } } as unknown as DtfClient; + const { client, readContract } = createChainClient({ + nextAuctionId: 0n, + auction: AUCTION, + rebalanceNonce: 0n, + block: { number: 1n, timestamp: 1n }, + }); await expect(getLatestAuction(client, { address: DTF, chainId: 1 })).resolves.toBeNull(); expect(readContract).toHaveBeenCalledTimes(1); }); - it("returns only the active latest auction from RPC", async () => { - const readContract = vi.fn().mockResolvedValueOnce(3n).mockResolvedValueOnce([9n, 999_900n, 1_000_100n]); - const getBlock = vi.fn(async () => ({ timestamp: 1_000_000n })); - const client = { - viem: { - readContract, - getPublicClient: vi.fn(() => ({ getBlock })), - }, - } as unknown as DtfClient; - - const auction = await getActiveAuction(client, { - address: DTF, - chainId: 1, - }); - - expect(auction).toEqual({ - auctionId: 2n, + // Folio: `block.timestamp >= startTime && block.timestamp <= endTime` (inclusive both ends). + it.each([ + { timestamp: 999_899n, active: false }, + { timestamp: 999_900n, active: true }, + { timestamp: 1_000_099n, active: true }, + { timestamp: 1_000_100n, active: true }, + { timestamp: 1_000_101n, active: false }, + ])("reports active=$active at timestamp $timestamp", async ({ timestamp, active }) => { + const { client } = createChainClient({ + nextAuctionId: 3n, + auction: AUCTION, rebalanceNonce: 9n, - startTime: 999_900n, - endTime: 1_000_100n, - isActive: true, + block: { number: 5n, timestamp }, }); + + const auction = await getActiveAuction(client, { address: DTF, chainId: 1 }); + + expect(auction === null).toBe(!active); }); - it("returns null when the latest auction is not active", async () => { - const readContract = vi.fn().mockResolvedValueOnce(3n).mockResolvedValueOnce([9n, 999_000n, 999_900n]); - const getBlock = vi.fn(async () => ({ timestamp: 1_000_000n })); - const client = { - viem: { - readContract, - getPublicClient: vi.fn(() => ({ getBlock })), - }, - } as unknown as DtfClient; + it("treats an atomic auction (start == end) as active only at that timestamp", async () => { + const atomic: MockChain["auction"] = [9n, 1_000_000n, 1_000_000n]; + const at = async (timestamp: bigint) => { + const { client } = createChainClient({ + nextAuctionId: 3n, + auction: atomic, + rebalanceNonce: 9n, + block: { number: 5n, timestamp }, + }); + return getActiveAuction(client, { address: DTF, chainId: 1 }); + }; + expect(await at(999_999n)).toBeNull(); + expect(await at(1_000_000n)).not.toBeNull(); + expect(await at(1_000_001n)).toBeNull(); + }); + + it("is not active when the latest auction belongs to a previous rebalance nonce", async () => { + const { client } = createChainClient({ + nextAuctionId: 3n, + auction: AUCTION, + rebalanceNonce: 10n, + block: { number: 5n, timestamp: 1_000_000n }, + }); + + const latest = await getLatestAuction(client, { address: DTF, chainId: 1 }); + + expect(latest).toMatchObject({ rebalanceNonce: 9n, currentRebalanceNonce: 10n, isActive: false }); await expect(getActiveAuction(client, { address: DTF, chainId: 1 })).resolves.toBeNull(); }); - it("uses block timestamp for historical active auction reads", async () => { - const readContract = vi.fn().mockResolvedValueOnce(3n).mockResolvedValueOnce([9n, 999_900n, 1_000_100n]); - const getBlock = vi.fn(async () => ({ timestamp: 1_000_000n })); - const client = { - viem: { - readContract, - getPublicClient: vi.fn(() => ({ getBlock })), - }, - } as unknown as DtfClient; - - const auction = await getActiveAuction(client, { - address: DTF, - chainId: 1, - blockNumber: 123n, + it("pins auction state, nonce and timestamp to the same historical block", async () => { + const { client, readContract, getBlock } = createChainClient({ + nextAuctionId: 3n, + auction: AUCTION, + rebalanceNonce: 9n, + block: { number: 123n, timestamp: 1_000_000n }, }); + const auction = await getActiveAuction(client, { address: DTF, chainId: 1, blockNumber: 123n }); + expect(getBlock).toHaveBeenCalledWith({ blockNumber: 123n }); - expect(auction?.isActive).toBe(true); + expect( + readContract.mock.calls.map(([{ chainId, address, functionName, blockNumber, args }]) => ({ + chainId, + address, + functionName, + blockNumber, + args, + })), + ).toEqual([ + { chainId: 1, address: DTF, functionName: "nextAuctionId", blockNumber: 123n, args: undefined }, + { chainId: 1, address: DTF, functionName: "auctions", args: [2n], blockNumber: 123n }, + { chainId: 1, address: DTF, functionName: "getRebalance", blockNumber: 123n, args: undefined }, + ]); + expect(auction).toMatchObject({ auctionId: 2n, isActive: true, blockNumber: 123n }); }); it("reads v5 bid quotes with stable token order", async () => { @@ -143,6 +208,7 @@ describe("Index DTF rebalance execution", () => { const bid = prepareIndexDtfBid({ address: DTF, chainId: 8453, + version: "5.0.0", auctionId: 4n, sellToken: SELL_TOKEN, buyToken: BUY_TOKEN, @@ -152,18 +218,102 @@ describe("Index DTF rebalance execution", () => { data: "0x1234", }); const close = prepareIndexDtfCloseAuction({ + address: DTF, + chainId: 8453, + version: "5.0.0", + auctionId: 4n, + }); + const end = prepareIndexDtfEndRebalance({ address: DTF, chainId: 8453, version: "5.0.0" }); + + expect([bid, close, end].map(({ to, chainId, value }) => ({ to, chainId, value }))).toEqual([ + { to: DTF, chainId: 8453, value: 0n }, + { to: DTF, chainId: 8453, value: 0n }, + { to: DTF, chainId: 8453, value: 0n }, + ]); + expect([bid, close, end].map(({ data }) => decodeFunctionData({ abi: dtfIndexAbi, data }))).toEqual([ + { functionName: "bid", args: [4n, SELL_TOKEN, BUY_TOKEN, 100n, 130n, true, "0x1234"] }, + { functionName: "closeAuction", args: [4n] }, + { functionName: "endRebalance", args: undefined }, + ]); + }); + + it("encodes bid and close auction identically for 5.0.0 and 6.0.0", () => { + const bidInput = { address: DTF, chainId: 8453, auctionId: 4n, + sellToken: SELL_TOKEN, + buyToken: BUY_TOKEN, + sellAmount: 100n, + maxBuyAmount: 130n, + } as const; + + const v5 = [ + prepareIndexDtfBid({ ...bidInput, version: "5.0.0" }), + prepareIndexDtfCloseAuction({ address: DTF, chainId: 8453, version: "5.0.0", auctionId: 4n }), + ]; + const v6 = [ + prepareIndexDtfBid({ ...bidInput, version: "6.0.0" }), + prepareIndexDtfCloseAuction({ address: DTF, chainId: 8453, version: "6.0.0", auctionId: 4n }), + ]; + + expect(v6.map((call) => call.data)).toEqual(v5.map((call) => call.data)); + expect(v6.map((call) => decodeFunctionData({ abi: folioArtifactAbi, data: call.data }).functionName)).toEqual([ + "bid", + "closeAuction", + ]); + expect(v5.map((call) => call.contract.abi === dtfIndexAbi)).toEqual([true, true]); + expect(v6.map((call) => call.contract.abi === folioArtifactAbi)).toEqual([true, true]); + }); + + it("binds the 6.0.0 end rebalance to a rebalance nonce and keeps 5.0.0 on endRebalance()", () => { + const v5 = prepareIndexDtfEndRebalance({ address: DTF, chainId: 8453, version: "5.0.0" }); + const v6 = prepareIndexDtfEndRebalance({ address: DTF, chainId: 8453, version: "6.0.0", rebalanceNonce: 3n }); + + // Folio 6.0.0 on-chain: endRebalance(uint256) = 0xf9ff9f9c; endRebalance() = 0x0040718e no longer dispatches. + expect(v5.data).toBe("0x0040718e"); + expect(v6.data).toBe(`0xf9ff9f9c${"3".padStart(64, "0")}`); + expect(decodeFunctionData({ abi: folioArtifactAbi, data: v6.data })).toEqual({ + functionName: "endRebalance", + args: [3n], }); - const end = prepareIndexDtfEndRebalance({ address: DTF, chainId: 8453 }); - - expect(bid.contract.functionName).toBe("bid"); - expect(bid.contract.args).toEqual([4n, SELL_TOKEN, BUY_TOKEN, 100n, 130n, true, "0x1234"]); - expect(close.contract.functionName).toBe("closeAuction"); - expect(close.contract.args).toEqual([4n]); - expect(end.contract.functionName).toBe("endRebalance"); - expect(end.contract.args).toEqual([]); + expect(v5.contract.abi).toBe(dtfIndexAbi); + expect(v6.contract.abi).toBe(folioArtifactAbi); + expect(v6.contract.args).toEqual([3n]); + }); + + it("rejects a 6.0.0 end rebalance without a rebalance nonce", () => { + for (const rebalanceNonce of [undefined, -1n, 1] as unknown as bigint[]) { + const build = () => + prepareIndexDtfEndRebalance({ address: DTF, chainId: 8453, version: "6.0.0", rebalanceNonce }); + + expect(build).toThrow(SdkError); + expect(build).toThrow(expect.objectContaining({ code: "INVALID_INPUT", meta: { rebalanceNonce } })); + } + }); + + it("rejects versions the write contract does not admit", () => { + const version = "4.0.0" as unknown as "5.0.0"; + const builders = [ + () => + prepareIndexDtfBid({ + address: DTF, + chainId: 8453, + version, + auctionId: 4n, + sellToken: SELL_TOKEN, + buyToken: BUY_TOKEN, + sellAmount: 100n, + maxBuyAmount: 130n, + }), + () => prepareIndexDtfCloseAuction({ address: DTF, chainId: 8453, version, auctionId: 4n }), + () => prepareIndexDtfEndRebalance({ address: DTF, chainId: 8453, version }), + ]; + + for (const build of builders) { + expect(build).toThrow(SdkError); + expect(build).toThrow(expect.objectContaining({ code: "INVALID_INPUT", meta: { version: "4.0.0" } })); + } }); it("rejects zero-size bids", () => { @@ -171,6 +321,7 @@ describe("Index DTF rebalance execution", () => { prepareIndexDtfBid({ address: DTF, chainId: 1, + version: "5.0.0", auctionId: 4n, sellToken: SELL_TOKEN, buyToken: BUY_TOKEN, diff --git a/packages/sdk/src/index-dtf/rebalance/execution.ts b/packages/sdk/src/index-dtf/rebalance/execution.ts index edb19c9..fbf3171 100644 --- a/packages/sdk/src/index-dtf/rebalance/execution.ts +++ b/packages/sdk/src/index-dtf/rebalance/execution.ts @@ -3,16 +3,22 @@ import { getAddress, type Address, type Hex } from "viem"; import type { DtfClient } from "@/client"; import type { SupportedChainId } from "@/config"; import type { DtfParams } from "@/types/common"; +import type { IndexDtfCall } from "@/types/governance"; import { dtfIndexAbi } from "@/index-dtf/abis/dtf-index-abi"; +import { getIndexDtfWriteAbi, type IndexDtfWriteVersion } from "@/index-dtf/write-version"; import { prepareContractCall } from "@/lib/contract-call"; import { SdkError } from "@/lib/errors"; export type IndexDtfLatestAuction = { readonly auctionId: bigint; readonly rebalanceNonce: bigint; + /** `getRebalance().nonce` at the same block; bids are rejected when it differs from `rebalanceNonce`. */ + readonly currentRebalanceNonce: bigint; readonly startTime: bigint; readonly endTime: bigint; + /** Block every field above was read at. */ + readonly blockNumber: bigint; readonly isActive: boolean; }; @@ -36,6 +42,7 @@ export type GetIndexDtfBidQuoteParams = DtfParams & { export type PrepareIndexDtfBidParams = { readonly address: Address; readonly chainId: SupportedChainId; + readonly version: IndexDtfWriteVersion; readonly auctionId: bigint; readonly sellToken: Address; readonly buyToken: Address; @@ -45,14 +52,22 @@ export type PrepareIndexDtfBidParams = { readonly data?: Hex; }; +/** + * Resolves one block first and pins every read to it: auction id, auction + * window, current rebalance nonce and timestamp all describe the same state. + * Folio accepts bids while `startTime <= now <= endTime` (inclusive) and only + * when the auction's nonce equals the current rebalance nonce. + */ export async function getLatestAuction(client: DtfClient, params: DtfParams): Promise { const address = getAddress(params.address); + const block = await getAuctionBlock(client, params); + const blockNumber = block.number; const nextAuctionId = await client.viem.readContract({ chainId: params.chainId, address, abi: dtfIndexAbi, functionName: "nextAuctionId", - blockNumber: params.blockNumber, + blockNumber, }); if (nextAuctionId === 0n) { @@ -60,22 +75,34 @@ export async function getLatestAuction(client: DtfClient, params: DtfParams): Pr } const auctionId = nextAuctionId - 1n; - const [rebalanceNonce, startTime, endTime] = await client.viem.readContract({ - chainId: params.chainId, - address, - abi: dtfIndexAbi, - functionName: "auctions", - args: [auctionId], - blockNumber: params.blockNumber, - }); - const now = await getAuctionTimestamp(client, params); + const [[rebalanceNonce, startTime, endTime], rebalance] = await Promise.all([ + client.viem.readContract({ + chainId: params.chainId, + address, + abi: dtfIndexAbi, + functionName: "auctions", + args: [auctionId], + blockNumber, + }), + client.viem.readContract({ + chainId: params.chainId, + address, + abi: dtfIndexAbi, + functionName: "getRebalance", + blockNumber, + }), + ]); + const currentRebalanceNonce = (rebalance as unknown as readonly unknown[])[0] as bigint; + const now = block.timestamp; return { auctionId, rebalanceNonce, + currentRebalanceNonce, startTime, endTime, - isActive: startTime <= now && now < endTime, + blockNumber, + isActive: rebalanceNonce === currentRebalanceNonce && startTime <= now && now <= endTime, }; } @@ -85,20 +112,17 @@ export async function getActiveAuction(client: DtfClient, params: DtfParams): Pr return auction?.isActive ? { ...auction, isActive: true } : null; } -async function getAuctionTimestamp(client: DtfClient, params: DtfParams): Promise { +async function getAuctionBlock( + client: DtfClient, + params: DtfParams, +): Promise<{ readonly number: bigint; readonly timestamp: bigint }> { const publicClient = client.viem.getPublicClient(params.chainId); + const block = + params.blockNumber === undefined + ? await publicClient.getBlock({}) + : await publicClient.getBlock({ blockNumber: params.blockNumber }); - if (params.blockNumber === undefined) { - const block = await publicClient.getBlock(); - - return block.timestamp; - } - - const block = await publicClient.getBlock({ - blockNumber: params.blockNumber, - }); - - return block.timestamp; + return { number: block.number, timestamp: block.timestamp }; } export async function getBidQuote(client: DtfClient, params: GetIndexDtfBidQuoteParams): Promise { @@ -114,7 +138,7 @@ export async function getBidQuote(client: DtfClient, params: GetIndexDtfBidQuote return { sellAmount, bidAmount, price }; } -export function prepareIndexDtfBid(params: PrepareIndexDtfBidParams) { +export function prepareIndexDtfBid(params: PrepareIndexDtfBidParams): IndexDtfCall { if (params.sellAmount <= 0n) { throw new SdkError({ code: "INVALID_INPUT", @@ -126,7 +150,7 @@ export function prepareIndexDtfBid(params: PrepareIndexDtfBidParams) { return prepareContractCall({ chainId: params.chainId, address: params.address, - abi: dtfIndexAbi, + abi: getIndexDtfWriteAbi(params.version), functionName: "bid", args: [ params.auctionId, @@ -143,22 +167,59 @@ export function prepareIndexDtfBid(params: PrepareIndexDtfBidParams) { export function prepareIndexDtfCloseAuction(params: { readonly address: Address; readonly chainId: SupportedChainId; + readonly version: IndexDtfWriteVersion; readonly auctionId: bigint; -}) { +}): IndexDtfCall { return prepareContractCall({ chainId: params.chainId, address: params.address, - abi: dtfIndexAbi, + abi: getIndexDtfWriteAbi(params.version), functionName: "closeAuction", args: [params.auctionId] as const, }); } -export function prepareIndexDtfEndRebalance(params: { readonly address: Address; readonly chainId: SupportedChainId }) { +export type PrepareIndexDtfEndRebalanceParams = + | { + readonly address: Address; + readonly chainId: SupportedChainId; + readonly version: "5.0.0"; + } + | { + readonly address: Address; + readonly chainId: SupportedChainId; + readonly version: "6.0.0"; + /** + * The rebalance being ended (`getRebalanceNonce()`); Folio 6.0 reverts `Folio__InvalidRebalanceNonce` on any + * other value, so a stale call cannot end a newer rebalance. `maxUint256` skips that check on-chain. + */ + readonly rebalanceNonce: bigint; + }; + +/** v5 encodes `endRebalance()`; Folio 6.0.0 encodes `endRebalance(uint256 rebalanceNonce)`. */ +export function prepareIndexDtfEndRebalance(params: PrepareIndexDtfEndRebalanceParams): IndexDtfCall { + if (params.version === "6.0.0") { + if (typeof params.rebalanceNonce !== "bigint" || params.rebalanceNonce < 0n) { + throw new SdkError({ + code: "INVALID_INPUT", + message: "rebalanceNonce is required to end an Index DTF 6.0.0 rebalance", + meta: { rebalanceNonce: params.rebalanceNonce }, + }); + } + + return prepareContractCall({ + chainId: params.chainId, + address: params.address, + abi: getIndexDtfWriteAbi(params.version), + functionName: "endRebalance", + args: [params.rebalanceNonce] as const, + }); + } + return prepareContractCall({ chainId: params.chainId, address: params.address, - abi: dtfIndexAbi, + abi: getIndexDtfWriteAbi(params.version), functionName: "endRebalance", args: [] as const, }); diff --git a/packages/sdk/src/index-dtf/rebalance/open-auction.integration.test.ts b/packages/sdk/src/index-dtf/rebalance/open-auction.integration.test.ts index 2ed642e..1b14067 100644 --- a/packages/sdk/src/index-dtf/rebalance/open-auction.integration.test.ts +++ b/packages/sdk/src/index-dtf/rebalance/open-auction.integration.test.ts @@ -1,8 +1,11 @@ import { PriceControl } from "@reserve-protocol/dtf-rebalance-lib"; +import { decodeFunctionData } from "viem"; import { describe, expect, it } from "vitest"; import type { IndexDtfOpenAuctionInput } from "@/index-dtf/rebalance/types"; +import { dtfIndexAbi } from "@/index-dtf/abis/dtf-index-abi"; +import { folioArtifactAbi } from "@/index-dtf/abis/folio-artifact"; import { prepareIndexDtfOpenAuction, prepareIndexDtfOpenAuctionArgs } from "@/index-dtf/rebalance/open-auction"; // Runs the REAL dtf-rebalance-lib (unlike open-auction.test.ts, which mocks it) @@ -38,28 +41,105 @@ describe("prepareIndexDtfOpenAuctionArgs with the real rebalance library", () => ...input, initialPrices: { ...input.initialPrices, [DAI_KEY]: 0 }, }), - ).toThrow(/missing price for token index/); + ).toThrow(/missing price for token/); }); it("throws on zero supply instead of building skewed auction args", () => { expect(() => prepareIndexDtfOpenAuctionArgs({ ...createInput(), supply: 0n })).toThrow(/Division by zero/); }); - it("builds exact openAuction calldata for a fixed rebalance snapshot", () => { + it.each([ + { version: "5.0.0", abi: dtfIndexAbi, selector: "0x3c46570f" }, + { version: "6.0.0", abi: folioArtifactAbi, selector: "0x9bd97b3e" }, + ] as const)("encodes the full auction payload for version $version", ({ version, abi, selector }) => { + const built = prepareIndexDtfOpenAuctionArgs({ + ...createInput(), + version, + ...(version === "6.0.0" ? { auctionLength: 1800n } : {}), + }); + const call = prepareIndexDtfOpenAuction({ + address: DTF, + chainId: 1, + version, + args: built.args, + }); + + expect(call).toMatchObject({ to: DTF, chainId: 1, value: 0n }); + expect(call.data.slice(0, 10)).toBe(selector); + expect(decodeFunctionData({ abi, data: call.data })).toEqual({ + functionName: "openAuction", + args: version === "6.0.0" ? [...EXPECTED_AUCTION_ARGS, 1800n] : EXPECTED_AUCTION_ARGS, + }); + }); + + it.each([undefined, "4.0.0"])("rejects version %s at the calldata boundary", (version) => { + const built = prepareIndexDtfOpenAuctionArgs(createInput()); + + expect(() => + prepareIndexDtfOpenAuction({ + address: DTF, + chainId: 1, + version: version as unknown as "5.0.0", + args: built.args, + }), + ).toThrow(/Unsupported Index DTF version/); + }); + + it("uses the auction length carried by v6 args when none is passed explicitly", () => { + const built = prepareIndexDtfOpenAuctionArgs({ ...createInput(), version: "6.0.0", auctionLength: 1800n }); + const call = prepareIndexDtfOpenAuction({ address: DTF, chainId: 1, version: "6.0.0", args: built.args }); + + expect(decodeFunctionData({ abi: folioArtifactAbi, data: call.data })).toEqual({ + functionName: "openAuction", + args: [...EXPECTED_AUCTION_ARGS, 1800n], + }); + }); + + it("rejects an explicit v6 auctionLength that diverges from the one the math used", () => { + const built = prepareIndexDtfOpenAuctionArgs({ ...createInput(), version: "6.0.0", auctionLength: 1800n }); + + expect(() => + prepareIndexDtfOpenAuction({ address: DTF, chainId: 1, version: "6.0.0", args: built.args, auctionLength: 900n }), + ).toThrow(/differs/); + }); + + it.each([undefined, 0n, -1n])("rejects invalid v6 auctionLength %s", (auctionLength) => { const built = prepareIndexDtfOpenAuctionArgs(createInput()); - const call = prepareIndexDtfOpenAuction({ address: DTF, chainId: 1, args: built.args }); - expect(call.to).toBe(DTF); - expect(call.data).toBe( - "0x3c46570f000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000d2f13f7789f00000000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000e92596fd62900000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000006b175474e89094c44da98b954eedeac495271d0f00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000001c6bf526340000000000000000000000000000000000000000000000000000001c6bf526340000000000000000000000000000000000000000000000000000001c6bf526340000000000000000000000000000000000000000000019d971e4fe8401e740000000000000000000000000000000000000000000000019d971e4fe8401e740000000000000000000000000000000000000000000000019d971e4fe8401e740000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000b5c0e8d21d902d61fa0000000000000000000000000000000000000000000000de24aac7eb3e705b4e00000000000000000000000000000000000000000000000000000000c7d713b49da00000000000000000000000000000000000000000000000000000f43fc2c04ee0000", - ); + expect(() => + prepareIndexDtfOpenAuction({ + address: DTF, + chainId: 1, + version: "6.0.0", + args: built.args, + ...(auctionLength === undefined ? {} : { auctionLength }), + }), + ).toThrow("auctionLength must be positive"); }); }); +// Golden vector: 50/50 spot weights in D27 token units, initial price ranges, +// and a 5% target-limit range around one basket per share. Keep this independent +// of the builder output so changes to rebalance math or argument order fail. +const EXPECTED_AUCTION_ARGS = [ + 3n, + [USDC, DAI], + [ + { low: 5n * 10n ** 14n, spot: 5n * 10n ** 14n, high: 5n * 10n ** 14n }, + { low: 5n * 10n ** 26n, spot: 5n * 10n ** 26n, high: 5n * 10n ** 26n }, + ], + [ + { low: 9n * 10n ** 29n, high: 11n * 10n ** 29n }, + { low: 9n * 10n ** 17n, high: 11n * 10n ** 17n }, + ], + { low: 95n * 10n ** 16n, spot: 10n ** 18n, high: 105n * 10n ** 16n }, +] as const; + // 1000 whole shares fully in USDC at $1, rebalancing toward a 50/50 USDC/DAI // basket — every value fixed so the built calldata is deterministic. function createInput(): IndexDtfOpenAuctionInput { return { + version: "5.0.0", rebalance: { nonce: 3n, priceControl: PriceControl.NONE, diff --git a/packages/sdk/src/index-dtf/rebalance/open-auction.test.ts b/packages/sdk/src/index-dtf/rebalance/open-auction.test.ts index fac0b46..0b6a46a 100644 --- a/packages/sdk/src/index-dtf/rebalance/open-auction.test.ts +++ b/packages/sdk/src/index-dtf/rebalance/open-auction.test.ts @@ -6,11 +6,18 @@ import { getTargetBasket, type WeightRange, } from "@reserve-protocol/dtf-rebalance-lib"; +import { decodeFunctionData } from "viem"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { IndexDtfOpenAuctionInput } from "@/index-dtf/rebalance/types"; -import { prepareIndexDtfOpenAuctionArgs } from "@/index-dtf/rebalance/open-auction"; +import { dtfIndexAbi } from "@/index-dtf/abis/dtf-index-abi"; +import { folioArtifactAbi } from "@/index-dtf/abis/folio-artifact"; +import { + prepareIndexDtfOpenAuctionArgs, + prepareIndexDtfOpenAuctionUnrestricted, +} from "@/index-dtf/rebalance/open-auction"; +import { SdkError } from "@/lib/errors"; vi.mock("@reserve-protocol/dtf-rebalance-lib", async (importOriginal) => { const actual = await importOriginal(); @@ -103,6 +110,65 @@ describe("prepareIndexDtfOpenAuctionArgs", () => { ); }); + it("passes v6 through the library's V6 branch with the auction length the calldata will use", () => { + const input = createInput({ version: "6.0.0", auctionLength: 1800n, rebalancePercent: 80 }); + const built = prepareIndexDtfOpenAuctionArgs(input); + + expect(built.args).toBeDefined(); + expect(getOpenAuction).toHaveBeenCalledWith( + FolioVersion.V6, + input.rebalance, + 1_000n, + 900n, + [50n], + [999n], + [75n], + [6n], + [3.5], + [0.02], + 0.8, + false, + 1800n, + ); + }); + + it("requires a positive auction length for v6 math", () => { + expect(() => prepareIndexDtfOpenAuctionArgs(createInput({ version: "6.0.0" }))).toThrow(/auctionLength/); + expect(() => prepareIndexDtfOpenAuctionArgs(createInput({ version: "6.0.0", auctionLength: 0n }))).toThrow( + /auctionLength/, + ); + expect(getOpenAuction).not.toHaveBeenCalled(); + }); + + it.each([undefined, "4.0.0", "5.1.0", "6.1.0"])("rejects unsupported version %s with a typed error", (version) => { + expect(() => prepareIndexDtfOpenAuctionArgs({ ...createInput(), version: version as unknown as "5.0.0" })).toThrow( + /Unsupported Index DTF version/, + ); + expect(getOpenAuction).not.toHaveBeenCalled(); + }); + + it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])( + "rejects a current price of %s before the rebalance library sees it", + (price) => { + const input = createInput(); + + expect(() => + prepareIndexDtfOpenAuctionArgs({ + ...input, + prices: { [TOKEN_KEY]: { currentPrice: price, snapshotPrice: 1 } }, + }), + ).toThrow(/missing price for token/); + expect(getOpenAuction).not.toHaveBeenCalled(); + }, + ); + + it("rejects a non-finite snapshot price when the target basket uses snapshot prices", () => { + expect(() => + prepareIndexDtfOpenAuctionArgs({ ...createInput(), initialPrices: { [TOKEN_KEY]: Number.NaN } }), + ).toThrow(/missing price for token/); + expect(getOpenAuction).not.toHaveBeenCalled(); + }); + it("rejects invalid rebalance percentages before calling the rebalance library", () => { expect(() => prepareIndexDtfOpenAuctionArgs(createInput({ rebalancePercent: 101 }))).toThrow( "rebalancePercent must be between 0 and 100", @@ -115,6 +181,7 @@ describe("prepareIndexDtfOpenAuctionArgs", () => { function createInput(overrides: Partial = {}): IndexDtfOpenAuctionInput { return { + version: "5.0.0", rebalance: { nonce: 7n, priceControl: PriceControl.NONE, @@ -161,3 +228,28 @@ function createInput(overrides: Partial = {}): IndexDt ...overrides, }; } + +describe("prepareIndexDtfOpenAuctionUnrestricted", () => { + it("encodes the community launch identically for 5.0.0 and 6.0.0 and rejects other versions", () => { + const address = "0x0000000000000000000000000000000000000001"; + const v5 = prepareIndexDtfOpenAuctionUnrestricted({ address, chainId: 56, version: "5.0.0", rebalanceNonce: 12n }); + const v6 = prepareIndexDtfOpenAuctionUnrestricted({ address, chainId: 56, version: "6.0.0", rebalanceNonce: 12n }); + + expect(v6.data).toBe(v5.data); + expect(decodeFunctionData({ abi: dtfIndexAbi, data: v5.data })).toEqual({ + functionName: "openAuctionUnrestricted", + args: [12n], + }); + expect(v5.contract.abi).toBe(dtfIndexAbi); + expect(v6.contract.abi).toBe(folioArtifactAbi); + const build = () => + prepareIndexDtfOpenAuctionUnrestricted({ + address, + chainId: 56, + version: "4.0.0" as unknown as "5.0.0", + rebalanceNonce: 12n, + }); + expect(build).toThrow(SdkError); + expect(build).toThrow(expect.objectContaining({ code: "INVALID_INPUT", meta: { version: "4.0.0" } })); + }); +}); diff --git a/packages/sdk/src/index-dtf/rebalance/open-auction.ts b/packages/sdk/src/index-dtf/rebalance/open-auction.ts index bef325a..13dfeb6 100644 --- a/packages/sdk/src/index-dtf/rebalance/open-auction.ts +++ b/packages/sdk/src/index-dtf/rebalance/open-auction.ts @@ -8,18 +8,24 @@ import type { IndexDtfTargetBasketPriceMode, OpenAuctionArgs, } from "@/index-dtf/rebalance/types"; +import type { IndexDtfCall } from "@/types/governance"; import { dtfIndexAbi } from "@/index-dtf/abis/dtf-index-abi"; +import { folioArtifactAbi } from "@/index-dtf/abis/folio-artifact"; +import { assertIndexDtfWriteVersion, getIndexDtfWriteAbi, type IndexDtfWriteVersion } from "@/index-dtf/write-version"; import { prepareContractCall } from "@/lib/contract-call"; import { SdkError } from "@/lib/errors"; /** - * Builds the v5 launcher `openAuction` args with `dtf-rebalance-lib`. + * Builds the launcher `openAuction` args with `dtf-rebalance-lib` for v5 or v6. * Historical and current inputs stay explicit so bots cannot mix time sources by accident. */ export function prepareIndexDtfOpenAuctionArgs(params: IndexDtfOpenAuctionInput): BuiltIndexDtfOpenAuction { + assertIndexDtfWriteVersion(params.version); validateOpenAuctionInput(params); + const auctionLength = params.version === "6.0.0" ? getRequiredAuctionLength(params.auctionLength) : undefined; + const targetMode = getTargetBasketPriceMode(params); const tokenMap = new Map(params.tokens.map((token) => [token.address.toLowerCase(), token])); const decimals: bigint[] = []; const currentPrices: number[] = []; @@ -45,6 +51,13 @@ export function prepareIndexDtfOpenAuctionArgs(params: IndexDtfOpenAuctionInput) meta: { token: address }, }); } + if (!isUsablePrice(prices.currentPrice) || (targetMode === "snapshot" && !isUsablePrice(initialPrice))) { + throw new SdkError({ + code: "INVALID_INPUT", + message: `missing price for token ${address}`, + meta: { token: address, currentPrice: prices.currentPrice, initialPrice }, + }); + } decimals.push(BigInt(metadata.decimals)); currentPrices.push(prices.currentPrice); @@ -55,10 +68,10 @@ export function prepareIndexDtfOpenAuctionArgs(params: IndexDtfOpenAuctionInput) weights.push(initialWeight); } - const targetPrices = getTargetBasketPriceMode(params) === "current" ? currentPrices : snapshotPrices; + const targetPrices = targetMode === "current" ? currentPrices : snapshotPrices; const targetBasket = getTargetBasket(weights, targetPrices, decimals, false); const [args, metrics] = getOpenAuction( - FolioVersion.V5, + params.version === "6.0.0" ? FolioVersion.V6 : FolioVersion.V5, params.rebalance, params.supply, params.initialSupply, @@ -70,11 +83,29 @@ export function prepareIndexDtfOpenAuctionArgs(params: IndexDtfOpenAuctionInput) priceError, params.rebalancePercent / 100, false, + ...(auctionLength === undefined ? [] : [auctionLength]), ); return { args, metrics, targetBasket }; } +// A zero, negative or non-finite price would skew weights silently; the lib only checks some of these. +function isUsablePrice(price: number | undefined): price is number { + return typeof price === "number" && Number.isFinite(price) && price > 0; +} + +function getRequiredAuctionLength(auctionLength: bigint | undefined): bigint { + if (auctionLength === undefined || auctionLength <= 0n) { + throw new SdkError({ + code: "INVALID_INPUT", + message: "auctionLength must be positive for Index DTF 6.0.0", + meta: { auctionLength }, + }); + } + + return auctionLength; +} + function validateOpenAuctionInput(params: IndexDtfOpenAuctionInput) { if (!Number.isFinite(params.rebalancePercent) || params.rebalancePercent < 0 || params.rebalancePercent > 100) { throw new SdkError({ @@ -99,13 +130,22 @@ function getTargetBasketPriceMode(params: IndexDtfOpenAuctionInput): IndexDtfTar return mode; } -/** Prepares a v5 launcher `openAuction(...)` contract call. */ -export function prepareIndexDtfOpenAuction(params: { +type PrepareIndexDtfOpenAuctionBaseParams = { readonly address: Address; readonly chainId: SupportedChainId; readonly args: OpenAuctionArgs; -}) { - const args = [ +}; + +export type PrepareIndexDtfOpenAuctionParams = PrepareIndexDtfOpenAuctionBaseParams & { + readonly version: IndexDtfWriteVersion; + /** v6 only; defaults to the length carried by args built with `prepareIndexDtfOpenAuctionArgs`. */ + readonly auctionLength?: bigint; +}; + +/** Prepares a version-aware launcher `openAuction(...)` contract call. */ +export function prepareIndexDtfOpenAuction(params: PrepareIndexDtfOpenAuctionParams) { + assertIndexDtfWriteVersion(params.version); + const commonArgs = [ params.args.rebalanceNonce, params.args.tokens.map((token) => getAddress(token as Address)), params.args.newWeights, @@ -113,12 +153,35 @@ export function prepareIndexDtfOpenAuction(params: { params.args.newLimits, ] as const; + if (params.version === "6.0.0") { + if ( + params.auctionLength !== undefined && + params.args.auctionLength !== undefined && + params.auctionLength !== params.args.auctionLength + ) { + throw new SdkError({ + code: "INVALID_INPUT", + message: "auctionLength differs from the length the auction args were built with", + meta: { auctionLength: params.auctionLength, argsAuctionLength: params.args.auctionLength }, + }); + } + const auctionLength = getRequiredAuctionLength(params.auctionLength ?? params.args.auctionLength); + + return prepareContractCall({ + chainId: params.chainId, + address: params.address, + abi: folioArtifactAbi, + functionName: "openAuction", + args: [...commonArgs, auctionLength] as const, + }); + } + return prepareContractCall({ chainId: params.chainId, address: params.address, abi: dtfIndexAbi, functionName: "openAuction", - args, + args: commonArgs, }); } @@ -126,12 +189,13 @@ export function prepareIndexDtfOpenAuction(params: { export function prepareIndexDtfOpenAuctionUnrestricted(params: { readonly address: Address; readonly chainId: SupportedChainId; + readonly version: IndexDtfWriteVersion; readonly rebalanceNonce: bigint; -}) { +}): IndexDtfCall { return prepareContractCall({ chainId: params.chainId, address: params.address, - abi: dtfIndexAbi, + abi: getIndexDtfWriteAbi(params.version), functionName: "openAuctionUnrestricted", args: [params.rebalanceNonce] as const, }); diff --git a/packages/sdk/src/index-dtf/rebalance/types.ts b/packages/sdk/src/index-dtf/rebalance/types.ts index 393c593..bc1a032 100644 --- a/packages/sdk/src/index-dtf/rebalance/types.ts +++ b/packages/sdk/src/index-dtf/rebalance/types.ts @@ -8,6 +8,7 @@ import type { } from "@reserve-protocol/dtf-rebalance-lib"; import type { Address } from "viem"; +import type { IndexDtfWriteVersion } from "@/index-dtf/governance/propose/calls"; import type { DtfParams, Token } from "@/types/common"; import type { IndexDtfInput } from "@/types/index-dtf"; @@ -130,7 +131,12 @@ export type IndexDtfCurrentRebalance = Rebalance & { export type IndexDtfTargetBasketPriceMode = "current" | "snapshot"; export type IndexDtfOpenAuctionInput = { - readonly rebalance: IndexDtfCurrentRebalance; + /** Folio version of the proxy; v4 is not admitted (Register-local), unknown releases are rejected. */ + readonly version: IndexDtfWriteVersion; + /** Required for v6: the same length the calldata will carry (protocol requires `maxAuctionLength` for PriceControl NONE). */ + readonly auctionLength?: bigint; + /** Live rebalance state; `bidsEnabled` is not part of the auction math, so a historical read without it is accepted. */ + readonly rebalance: Rebalance & { readonly bidsEnabled?: boolean }; readonly tokens: readonly Token[]; readonly supply: bigint; readonly initialSupply: bigint; diff --git a/packages/sdk/src/index-dtf/ref.ts b/packages/sdk/src/index-dtf/ref.ts index 1509c26..7e3d360 100644 --- a/packages/sdk/src/index-dtf/ref.ts +++ b/packages/sdk/src/index-dtf/ref.ts @@ -11,6 +11,13 @@ import type { import { getIndexDtfAccountBalanceSnapshot } from "@/index-dtf/dtf/account-pnl"; import { getIndexDtfExposure } from "@/index-dtf/dtf/exposure"; +import { + getIndexDtfImmutableFeeRecipients, + getIndexDtfIsTokenAllowlisted, + getIndexDtfMaxAuctionLength, + getIndexDtfSelfFee, + getIndexDtfTradeAllowlist, +} from "@/index-dtf/dtf/folio-v6"; import { getBasket, getBasketSnapshot, @@ -48,6 +55,7 @@ import { getBidQuote, getLatestAuction, getIndexDtfCurrentRebalance, + getIndexDtfRebalanceNonce, getIndexDtfRebalanceLiquidity, getCompletedRebalance, getCompletedRebalances, @@ -67,6 +75,7 @@ import { import { createIndexDtfVoteLockRef } from "@/index-dtf/vote-lock/ref"; type BlockNumberOption = Pick; +type WithoutTarget = T extends unknown ? Omit : never; export type IndexDtfRef = ReturnType; @@ -114,6 +123,12 @@ export function createIndexDtfRef(client: DtfClient, params: DtfParams) { chainId, ...blockParams(options), }), + getMaxAuctionLength: (options?: BlockNumberOption | BlockNumber) => + getIndexDtfMaxAuctionLength(client, { + address, + chainId, + ...blockParams(options), + }), getRebalanceControl: (options?: BlockNumberOption | BlockNumber) => getIndexDtfRebalanceControl(client, { address, @@ -153,7 +168,8 @@ export function createIndexDtfRef(client: DtfClient, params: DtfParams) { address, chainId, }), - prepareDistributeFees: () => prepareIndexDtfDistributeFees({ address, chainId }), + prepareDistributeFees: (call: Omit[0], "address" | "chainId">) => + prepareIndexDtfDistributeFees({ ...call, address, chainId }), ...createIndexDtfGovernanceRef(client, { address, chainId }), getRebalances: (options: Omit[1], "address" | "chainId"> = {}) => getRebalances(client, { ...options, address, chainId }), @@ -182,13 +198,23 @@ export function createIndexDtfRef(client: DtfClient, params: DtfParams) { getActiveAuction(client, { address, chainId, ...blockParams(options) }), getLatestAuction: (options?: BlockNumberOption | BlockNumber) => getLatestAuction(client, { address, chainId, ...blockParams(options) }), + getRebalanceNonce: (options?: BlockNumberOption | BlockNumber) => + getIndexDtfRebalanceNonce(client, { address, chainId, ...blockParams(options) }), + getSelfFee: (options?: BlockNumberOption | BlockNumber) => + getIndexDtfSelfFee(client, { address, chainId, ...blockParams(options) }), + getImmutableFeeRecipients: (options?: BlockNumberOption | BlockNumber) => + getIndexDtfImmutableFeeRecipients(client, { address, chainId, ...blockParams(options) }), + getTradeAllowlist: (options?: BlockNumberOption | BlockNumber) => + getIndexDtfTradeAllowlist(client, { address, chainId, ...blockParams(options) }), + getIsTokenAllowlisted: (token: Address) => getIndexDtfIsTokenAllowlisted(client, { address, chainId, token }), getBidQuote: (quote: Omit[1], "address" | "chainId">) => getBidQuote(client, { ...quote, address, chainId }), prepareBid: (call: Omit[0], "address" | "chainId">) => prepareIndexDtfBid({ ...call, address, chainId }), prepareCloseAuction: (call: Omit[0], "address" | "chainId">) => prepareIndexDtfCloseAuction({ ...call, address, chainId }), - prepareEndRebalance: () => prepareIndexDtfEndRebalance({ address, chainId }), + prepareEndRebalance: (call: WithoutTarget[0]>) => + prepareIndexDtfEndRebalance({ ...call, address, chainId }), prepareOpenAuctionArgs: prepareIndexDtfOpenAuctionArgs, prepareOpenAuction: (call: Omit[0], "address" | "chainId">) => prepareIndexDtfOpenAuction({ ...call, address, chainId }), diff --git a/packages/sdk/src/index-dtf/subgraph/dtf.generated.ts b/packages/sdk/src/index-dtf/subgraph/dtf.generated.ts index 88a57e4..6e0f054 100644 --- a/packages/sdk/src/index-dtf/subgraph/dtf.generated.ts +++ b/packages/sdk/src/index-dtf/subgraph/dtf.generated.ts @@ -523,20 +523,26 @@ export type AuctionBid_OrderBy = | 'dtf__deployer' | 'dtf__externalRevenue' | 'dtf__feeRecipients' + | 'dtf__folioFeeForSelf' | 'dtf__governanceRevenue' | 'dtf__id' + | 'dtf__immutableFeeRecipients' | 'dtf__mandate' + | 'dtf__maxAuctionLength' | 'dtf__mintingFee' | 'dtf__ownerAddress' | 'dtf__priceControl' | 'dtf__protocolRevenue' | 'dtf__proxyAdmin' + | 'dtf__selfRevenue' | 'dtf__stTokenAddress' | 'dtf__timestamp' | 'dtf__totalRevenue' + | 'dtf__tradeAllowlistEnabled' | 'dtf__trustedFillerEnabled' | 'dtf__trustedFillerRegistry' | 'dtf__tvlFee' + | 'dtf__version' | 'dtf__weightControl' | 'id' | 'sellAmount' @@ -716,20 +722,26 @@ export type Auction_OrderBy = | 'dtf__deployer' | 'dtf__externalRevenue' | 'dtf__feeRecipients' + | 'dtf__folioFeeForSelf' | 'dtf__governanceRevenue' | 'dtf__id' + | 'dtf__immutableFeeRecipients' | 'dtf__mandate' + | 'dtf__maxAuctionLength' | 'dtf__mintingFee' | 'dtf__ownerAddress' | 'dtf__priceControl' | 'dtf__protocolRevenue' | 'dtf__proxyAdmin' + | 'dtf__selfRevenue' | 'dtf__stTokenAddress' | 'dtf__timestamp' | 'dtf__totalRevenue' + | 'dtf__tradeAllowlistEnabled' | 'dtf__trustedFillerEnabled' | 'dtf__trustedFillerRegistry' | 'dtf__tvlFee' + | 'dtf__version' | 'dtf__weightControl' | 'endTime' | 'id' @@ -872,6 +884,14 @@ export type Dtf_Filter = { feeRecipients_not_starts_with_nocase?: InputMaybe; feeRecipients_starts_with?: InputMaybe; feeRecipients_starts_with_nocase?: InputMaybe; + folioFeeForSelf?: InputMaybe; + folioFeeForSelf_gt?: InputMaybe; + folioFeeForSelf_gte?: InputMaybe; + folioFeeForSelf_in?: InputMaybe>; + folioFeeForSelf_lt?: InputMaybe; + folioFeeForSelf_lte?: InputMaybe; + folioFeeForSelf_not?: InputMaybe; + folioFeeForSelf_not_in?: InputMaybe>; governanceRevenue?: InputMaybe; governanceRevenue_gt?: InputMaybe; governanceRevenue_gte?: InputMaybe; @@ -888,6 +908,33 @@ export type Dtf_Filter = { id_lte?: InputMaybe; id_not?: InputMaybe; id_not_in?: InputMaybe>; + immutableFeeRecipients?: InputMaybe; + immutableFeeRecipients_contains?: InputMaybe; + immutableFeeRecipients_contains_nocase?: InputMaybe; + immutableFeeRecipients_ends_with?: InputMaybe; + immutableFeeRecipients_ends_with_nocase?: InputMaybe; + immutableFeeRecipients_gt?: InputMaybe; + immutableFeeRecipients_gte?: InputMaybe; + immutableFeeRecipients_in?: InputMaybe>; + immutableFeeRecipients_lt?: InputMaybe; + immutableFeeRecipients_lte?: InputMaybe; + immutableFeeRecipients_not?: InputMaybe; + immutableFeeRecipients_not_contains?: InputMaybe; + immutableFeeRecipients_not_contains_nocase?: InputMaybe; + immutableFeeRecipients_not_ends_with?: InputMaybe; + immutableFeeRecipients_not_ends_with_nocase?: InputMaybe; + immutableFeeRecipients_not_in?: InputMaybe>; + immutableFeeRecipients_not_starts_with?: InputMaybe; + immutableFeeRecipients_not_starts_with_nocase?: InputMaybe; + immutableFeeRecipients_starts_with?: InputMaybe; + immutableFeeRecipients_starts_with_nocase?: InputMaybe; + legacyAdminGovernances?: InputMaybe>; + legacyAdminGovernances_?: InputMaybe; + legacyAdminGovernances_contains?: InputMaybe>; + legacyAdminGovernances_contains_nocase?: InputMaybe>; + legacyAdminGovernances_not?: InputMaybe>; + legacyAdminGovernances_not_contains?: InputMaybe>; + legacyAdminGovernances_not_contains_nocase?: InputMaybe>; legacyAdmins?: InputMaybe>; legacyAdmins_contains?: InputMaybe>; legacyAdmins_contains_nocase?: InputMaybe>; @@ -900,6 +947,13 @@ export type Dtf_Filter = { legacyAuctionApprovers_not?: InputMaybe>; legacyAuctionApprovers_not_contains?: InputMaybe>; legacyAuctionApprovers_not_contains_nocase?: InputMaybe>; + legacyTradingGovernances?: InputMaybe>; + legacyTradingGovernances_?: InputMaybe; + legacyTradingGovernances_contains?: InputMaybe>; + legacyTradingGovernances_contains_nocase?: InputMaybe>; + legacyTradingGovernances_not?: InputMaybe>; + legacyTradingGovernances_not_contains?: InputMaybe>; + legacyTradingGovernances_not_contains_nocase?: InputMaybe>; mandate?: InputMaybe; mandate_contains?: InputMaybe; mandate_contains_nocase?: InputMaybe; @@ -920,6 +974,14 @@ export type Dtf_Filter = { mandate_not_starts_with_nocase?: InputMaybe; mandate_starts_with?: InputMaybe; mandate_starts_with_nocase?: InputMaybe; + maxAuctionLength?: InputMaybe; + maxAuctionLength_gt?: InputMaybe; + maxAuctionLength_gte?: InputMaybe; + maxAuctionLength_in?: InputMaybe>; + maxAuctionLength_lt?: InputMaybe; + maxAuctionLength_lte?: InputMaybe; + maxAuctionLength_not?: InputMaybe; + maxAuctionLength_not_in?: InputMaybe>; mintingFee?: InputMaybe; mintingFee_gt?: InputMaybe; mintingFee_gte?: InputMaybe; @@ -987,6 +1049,14 @@ export type Dtf_Filter = { proxyAdmin_not_contains?: InputMaybe; proxyAdmin_not_in?: InputMaybe>; rebalances_?: InputMaybe; + selfRevenue?: InputMaybe; + selfRevenue_gt?: InputMaybe; + selfRevenue_gte?: InputMaybe; + selfRevenue_in?: InputMaybe>; + selfRevenue_lt?: InputMaybe; + selfRevenue_lte?: InputMaybe; + selfRevenue_not?: InputMaybe; + selfRevenue_not_in?: InputMaybe>; stToken?: InputMaybe; stTokenAddress?: InputMaybe; stTokenAddress_contains?: InputMaybe; @@ -1055,6 +1125,16 @@ export type Dtf_Filter = { totalRevenue_lte?: InputMaybe; totalRevenue_not?: InputMaybe; totalRevenue_not_in?: InputMaybe>; + tradeAllowlistEnabled?: InputMaybe; + tradeAllowlistEnabled_in?: InputMaybe>; + tradeAllowlistEnabled_not?: InputMaybe; + tradeAllowlistEnabled_not_in?: InputMaybe>; + tradeTokenAllowlist?: InputMaybe>; + tradeTokenAllowlist_contains?: InputMaybe>; + tradeTokenAllowlist_contains_nocase?: InputMaybe>; + tradeTokenAllowlist_not?: InputMaybe>; + tradeTokenAllowlist_not_contains?: InputMaybe>; + tradeTokenAllowlist_not_contains_nocase?: InputMaybe>; tradingGovernance?: InputMaybe; tradingGovernance_?: InputMaybe; tradingGovernance_contains?: InputMaybe; @@ -1098,6 +1178,26 @@ export type Dtf_Filter = { tvlFee_lte?: InputMaybe; tvlFee_not?: InputMaybe; tvlFee_not_in?: InputMaybe>; + version?: InputMaybe; + version_contains?: InputMaybe; + version_contains_nocase?: InputMaybe; + version_ends_with?: InputMaybe; + version_ends_with_nocase?: InputMaybe; + version_gt?: InputMaybe; + version_gte?: InputMaybe; + version_in?: InputMaybe>; + version_lt?: InputMaybe; + version_lte?: InputMaybe; + version_not?: InputMaybe; + version_not_contains?: InputMaybe; + version_not_contains_nocase?: InputMaybe; + version_not_ends_with?: InputMaybe; + version_not_ends_with_nocase?: InputMaybe; + version_not_in?: InputMaybe>; + version_not_starts_with?: InputMaybe; + version_not_starts_with_nocase?: InputMaybe; + version_starts_with?: InputMaybe; + version_starts_with_nocase?: InputMaybe; weightControl?: InputMaybe; weightControl_in?: InputMaybe>; weightControl_not?: InputMaybe; @@ -1117,11 +1217,16 @@ export type Dtf_OrderBy = | 'deployer' | 'externalRevenue' | 'feeRecipients' + | 'folioFeeForSelf' | 'governanceRevenue' | 'id' + | 'immutableFeeRecipients' + | 'legacyAdminGovernances' | 'legacyAdmins' | 'legacyAuctionApprovers' + | 'legacyTradingGovernances' | 'mandate' + | 'maxAuctionLength' | 'mintingFee' | 'ownerAddress' | 'ownerGovernance' @@ -1148,6 +1253,7 @@ export type Dtf_OrderBy = | 'protocolRevenue' | 'proxyAdmin' | 'rebalances' + | 'selfRevenue' | 'stToken' | 'stTokenAddress' | 'stToken__currentDelegates' @@ -1184,6 +1290,8 @@ export type Dtf_OrderBy = | 'token__transferCount' | 'token__type' | 'totalRevenue' + | 'tradeAllowlistEnabled' + | 'tradeTokenAllowlist' | 'tradingGovernance' | 'tradingGovernance__id' | 'tradingGovernance__isOptimistic' @@ -1207,6 +1315,7 @@ export type Dtf_OrderBy = | 'trustedFillerEnabled' | 'trustedFillerRegistry' | 'tvlFee' + | 'version' | 'weightControl'; export type DelegateChange_Filter = { @@ -1820,6 +1929,79 @@ export type Event_OrderBy = | 'token__transferCount' | 'token__type'; +export type FolioVersion_Filter = { + /** Filter for the block changed event. */ + _change_block?: InputMaybe; + and?: InputMaybe>>; + blockNumber?: InputMaybe; + blockNumber_gt?: InputMaybe; + blockNumber_gte?: InputMaybe; + blockNumber_in?: InputMaybe>; + blockNumber_lt?: InputMaybe; + blockNumber_lte?: InputMaybe; + blockNumber_not?: InputMaybe; + blockNumber_not_in?: InputMaybe>; + deployer?: InputMaybe; + deployer_contains?: InputMaybe; + deployer_gt?: InputMaybe; + deployer_gte?: InputMaybe; + deployer_in?: InputMaybe>; + deployer_lt?: InputMaybe; + deployer_lte?: InputMaybe; + deployer_not?: InputMaybe; + deployer_not_contains?: InputMaybe; + deployer_not_in?: InputMaybe>; + deprecated?: InputMaybe; + deprecated_in?: InputMaybe>; + deprecated_not?: InputMaybe; + deprecated_not_in?: InputMaybe>; + id?: InputMaybe; + id_gt?: InputMaybe; + id_gte?: InputMaybe; + id_in?: InputMaybe>; + id_lt?: InputMaybe; + id_lte?: InputMaybe; + id_not?: InputMaybe; + id_not_in?: InputMaybe>; + or?: InputMaybe>>; + timestamp?: InputMaybe; + timestamp_gt?: InputMaybe; + timestamp_gte?: InputMaybe; + timestamp_in?: InputMaybe>; + timestamp_lt?: InputMaybe; + timestamp_lte?: InputMaybe; + timestamp_not?: InputMaybe; + timestamp_not_in?: InputMaybe>; + version?: InputMaybe; + version_contains?: InputMaybe; + version_contains_nocase?: InputMaybe; + version_ends_with?: InputMaybe; + version_ends_with_nocase?: InputMaybe; + version_gt?: InputMaybe; + version_gte?: InputMaybe; + version_in?: InputMaybe>; + version_lt?: InputMaybe; + version_lte?: InputMaybe; + version_not?: InputMaybe; + version_not_contains?: InputMaybe; + version_not_contains_nocase?: InputMaybe; + version_not_ends_with?: InputMaybe; + version_not_ends_with_nocase?: InputMaybe; + version_not_in?: InputMaybe>; + version_not_starts_with?: InputMaybe; + version_not_starts_with_nocase?: InputMaybe; + version_starts_with?: InputMaybe; + version_starts_with_nocase?: InputMaybe; +}; + +export type FolioVersion_OrderBy = + | 'blockNumber' + | 'deployer' + | 'deprecated' + | 'id' + | 'timestamp' + | 'version'; + export type GovernanceTimelock_Filter = { /** Filter for the block changed event. */ _change_block?: InputMaybe; @@ -3704,20 +3886,26 @@ export type RebalanceAuctionBid_OrderBy = | 'dtf__deployer' | 'dtf__externalRevenue' | 'dtf__feeRecipients' + | 'dtf__folioFeeForSelf' | 'dtf__governanceRevenue' | 'dtf__id' + | 'dtf__immutableFeeRecipients' | 'dtf__mandate' + | 'dtf__maxAuctionLength' | 'dtf__mintingFee' | 'dtf__ownerAddress' | 'dtf__priceControl' | 'dtf__protocolRevenue' | 'dtf__proxyAdmin' + | 'dtf__selfRevenue' | 'dtf__stTokenAddress' | 'dtf__timestamp' | 'dtf__totalRevenue' + | 'dtf__tradeAllowlistEnabled' | 'dtf__trustedFillerEnabled' | 'dtf__trustedFillerRegistry' | 'dtf__tvlFee' + | 'dtf__version' | 'dtf__weightControl' | 'filler' | 'id' @@ -3942,20 +4130,26 @@ export type Rebalance_OrderBy = | 'dtf__deployer' | 'dtf__externalRevenue' | 'dtf__feeRecipients' + | 'dtf__folioFeeForSelf' | 'dtf__governanceRevenue' | 'dtf__id' + | 'dtf__immutableFeeRecipients' | 'dtf__mandate' + | 'dtf__maxAuctionLength' | 'dtf__mintingFee' | 'dtf__ownerAddress' | 'dtf__priceControl' | 'dtf__protocolRevenue' | 'dtf__proxyAdmin' + | 'dtf__selfRevenue' | 'dtf__stTokenAddress' | 'dtf__timestamp' | 'dtf__totalRevenue' + | 'dtf__tradeAllowlistEnabled' | 'dtf__trustedFillerEnabled' | 'dtf__trustedFillerRegistry' | 'dtf__tvlFee' + | 'dtf__version' | 'dtf__weightControl' | 'id' | 'inRebalance' @@ -5607,6 +5801,14 @@ export type TokenDailySnapshot_Filter = { dailyRevenue_lte?: InputMaybe; dailyRevenue_not?: InputMaybe; dailyRevenue_not_in?: InputMaybe>; + dailySelfRevenue?: InputMaybe; + dailySelfRevenue_gt?: InputMaybe; + dailySelfRevenue_gte?: InputMaybe; + dailySelfRevenue_in?: InputMaybe>; + dailySelfRevenue_lt?: InputMaybe; + dailySelfRevenue_lte?: InputMaybe; + dailySelfRevenue_not?: InputMaybe; + dailySelfRevenue_not_in?: InputMaybe>; dailyTotalSupply?: InputMaybe; dailyTotalSupply_gt?: InputMaybe; dailyTotalSupply_gte?: InputMaybe; @@ -5684,6 +5886,7 @@ export type TokenDailySnapshot_OrderBy = | 'dailyMintCount' | 'dailyProtocolRevenue' | 'dailyRevenue' + | 'dailySelfRevenue' | 'dailyTotalSupply' | 'dailyTransferAmount' | 'dailyTransferCount' @@ -5805,6 +6008,14 @@ export type TokenHourlySnapshot_Filter = { hourlyRevenue_lte?: InputMaybe; hourlyRevenue_not?: InputMaybe; hourlyRevenue_not_in?: InputMaybe>; + hourlySelfRevenue?: InputMaybe; + hourlySelfRevenue_gt?: InputMaybe; + hourlySelfRevenue_gte?: InputMaybe; + hourlySelfRevenue_in?: InputMaybe>; + hourlySelfRevenue_lt?: InputMaybe; + hourlySelfRevenue_lte?: InputMaybe; + hourlySelfRevenue_not?: InputMaybe; + hourlySelfRevenue_not_in?: InputMaybe>; hourlyTotalSupply?: InputMaybe; hourlyTotalSupply_gt?: InputMaybe; hourlyTotalSupply_gte?: InputMaybe; @@ -5882,6 +6093,7 @@ export type TokenHourlySnapshot_OrderBy = | 'hourlyMintCount' | 'hourlyProtocolRevenue' | 'hourlyRevenue' + | 'hourlySelfRevenue' | 'hourlyTotalSupply' | 'hourlyTransferAmount' | 'hourlyTransferCount' @@ -5971,6 +6183,14 @@ export type TokenMonthlySnapshot_Filter = { cumulativeRevenue_lte?: InputMaybe; cumulativeRevenue_not?: InputMaybe; cumulativeRevenue_not_in?: InputMaybe>; + cumulativeSelfRevenue?: InputMaybe; + cumulativeSelfRevenue_gt?: InputMaybe; + cumulativeSelfRevenue_gte?: InputMaybe; + cumulativeSelfRevenue_in?: InputMaybe>; + cumulativeSelfRevenue_lt?: InputMaybe; + cumulativeSelfRevenue_lte?: InputMaybe; + cumulativeSelfRevenue_not?: InputMaybe; + cumulativeSelfRevenue_not_in?: InputMaybe>; currentHolderCount?: InputMaybe; currentHolderCount_gt?: InputMaybe; currentHolderCount_gte?: InputMaybe; @@ -6059,6 +6279,14 @@ export type TokenMonthlySnapshot_Filter = { monthlyRevenue_lte?: InputMaybe; monthlyRevenue_not?: InputMaybe; monthlyRevenue_not_in?: InputMaybe>; + monthlySelfRevenue?: InputMaybe; + monthlySelfRevenue_gt?: InputMaybe; + monthlySelfRevenue_gte?: InputMaybe; + monthlySelfRevenue_in?: InputMaybe>; + monthlySelfRevenue_lt?: InputMaybe; + monthlySelfRevenue_lte?: InputMaybe; + monthlySelfRevenue_not?: InputMaybe; + monthlySelfRevenue_not_in?: InputMaybe>; monthlyTotalSupply?: InputMaybe; monthlyTotalSupply_gt?: InputMaybe; monthlyTotalSupply_gte?: InputMaybe; @@ -6124,6 +6352,7 @@ export type TokenMonthlySnapshot_OrderBy = | 'cumulativeMintAmount' | 'cumulativeProtocolRevenue' | 'cumulativeRevenue' + | 'cumulativeSelfRevenue' | 'currentHolderCount' | 'id' | 'monthlyBurnAmount' @@ -6135,6 +6364,7 @@ export type TokenMonthlySnapshot_OrderBy = | 'monthlyMintCount' | 'monthlyProtocolRevenue' | 'monthlyRevenue' + | 'monthlySelfRevenue' | 'monthlyTotalSupply' | 'monthlyTransferAmount' | 'monthlyTransferCount' @@ -6718,20 +6948,26 @@ export type Trade_OrderBy = | 'dtf__deployer' | 'dtf__externalRevenue' | 'dtf__feeRecipients' + | 'dtf__folioFeeForSelf' | 'dtf__governanceRevenue' | 'dtf__id' + | 'dtf__immutableFeeRecipients' | 'dtf__mandate' + | 'dtf__maxAuctionLength' | 'dtf__mintingFee' | 'dtf__ownerAddress' | 'dtf__priceControl' | 'dtf__protocolRevenue' | 'dtf__proxyAdmin' + | 'dtf__selfRevenue' | 'dtf__stTokenAddress' | 'dtf__timestamp' | 'dtf__totalRevenue' + | 'dtf__tradeAllowlistEnabled' | 'dtf__trustedFillerEnabled' | 'dtf__trustedFillerRegistry' | 'dtf__tvlFee' + | 'dtf__version' | 'dtf__weightControl' | 'end' | 'endPrice' @@ -7425,7 +7661,7 @@ export type GetIndexDtfQueryVariables = Exact<{ }>; -export type GetIndexDtfQuery = { dtf?: { id: string, proxyAdmin: string, timestamp: string, deployer: string, ownerAddress: string, admins: Array, mintingFee: string, tvlFee: string, annualizedTvlFee: string, mandate: string, auctionDelay: string, auctionLength: string, auctionApprovers: Array, auctionLaunchers: Array, brandManagers: Array, totalRevenue: string, protocolRevenue: string, governanceRevenue: string, externalRevenue: string, feeRecipients: string, bidsEnabled?: boolean | null, trustedFillerRegistry?: string | null, trustedFillerEnabled?: boolean | null, weightControl: boolean, priceControl: number, legacyAdmins: Array, legacyAuctionApprovers: Array, ownerGovernance?: { id: string, name: string, version: string, votingDelay: string, votingPeriod: string, proposalThreshold: string, quorumVotes?: string | null, quorumNumerator?: string | null, quorumDenominator?: string | null, isOptimistic?: boolean | null, optimisticVetoDelay?: string | null, optimisticVetoPeriod?: string | null, optimisticVetoThreshold?: string | null, optimisticProposalThrottleCapacity?: string | null, optimisticSelectorRegistry?: string | null, optimisticProposers?: Array | null, timelock: { id: string, guardians: Array, optimisticProposers?: Array | null, executionDelay: string, type: string } } | null, tradingGovernance?: { id: string, name: string, version: string, votingDelay: string, votingPeriod: string, proposalThreshold: string, quorumVotes?: string | null, quorumNumerator?: string | null, quorumDenominator?: string | null, isOptimistic?: boolean | null, optimisticVetoDelay?: string | null, optimisticVetoPeriod?: string | null, optimisticVetoThreshold?: string | null, optimisticProposalThrottleCapacity?: string | null, optimisticSelectorRegistry?: string | null, optimisticProposers?: Array | null, timelock: { id: string, guardians: Array, optimisticProposers?: Array | null, executionDelay: string, type: string } } | null, token: { id: string, address: string, name: string, symbol: string, decimals: number, totalSupply: string, currentHolderCount: string, cumulativeHolderCount: string, transferCount: string, mintCount: string, burnCount: string, totalBurned: string, totalMinted: string }, stToken?: { id: string, currentDelegates: string, totalDelegates: string, delegatedVotesRaw: string, currentOptimisticDelegates: string, totalOptimisticDelegates: string, optimisticDelegatedVotesRaw: string, legacyGovernance: Array, token: { id: string, address: string, name: string, symbol: string, decimals: number, totalSupply: string, currentHolderCount: string, cumulativeHolderCount: string, transferCount: string, mintCount: string, burnCount: string, totalBurned: string, totalMinted: string }, underlying?: { name: string, symbol: string, address: string, decimals: number } | null, governance?: { id: string, name: string, version: string, votingDelay: string, votingPeriod: string, proposalThreshold: string, quorumVotes?: string | null, quorumNumerator?: string | null, quorumDenominator?: string | null, isOptimistic?: boolean | null, optimisticVetoDelay?: string | null, optimisticVetoPeriod?: string | null, optimisticVetoThreshold?: string | null, optimisticProposalThrottleCapacity?: string | null, optimisticSelectorRegistry?: string | null, optimisticProposers?: Array | null, timelock: { id: string, guardians: Array, optimisticProposers?: Array | null, executionDelay: string, type: string } } | null, rewards: Array<{ rewardToken: { address: string, name: string, symbol: string, decimals: number } }> } | null } | null }; +export type GetIndexDtfQuery = { dtf?: { id: string, version?: string | null, proxyAdmin: string, timestamp: string, deployer: string, ownerAddress: string, admins: Array, mintingFee: string, tvlFee: string, annualizedTvlFee: string, mandate: string, auctionDelay: string, auctionLength: string, maxAuctionLength?: string | null, tradeAllowlistEnabled?: boolean | null, tradeTokenAllowlist?: Array | null, auctionApprovers: Array, auctionLaunchers: Array, brandManagers: Array, totalRevenue: string, protocolRevenue: string, governanceRevenue: string, externalRevenue: string, selfRevenue?: string | null, feeRecipients: string, immutableFeeRecipients?: string | null, folioFeeForSelf?: string | null, bidsEnabled?: boolean | null, trustedFillerRegistry?: string | null, trustedFillerEnabled?: boolean | null, weightControl: boolean, priceControl: number, legacyAdmins: Array, legacyAuctionApprovers: Array, ownerGovernance?: { id: string, name: string, version: string, votingDelay: string, votingPeriod: string, proposalThreshold: string, quorumVotes?: string | null, quorumNumerator?: string | null, quorumDenominator?: string | null, isOptimistic?: boolean | null, optimisticVetoDelay?: string | null, optimisticVetoPeriod?: string | null, optimisticVetoThreshold?: string | null, optimisticProposalThrottleCapacity?: string | null, optimisticSelectorRegistry?: string | null, optimisticProposers?: Array | null, timelock: { id: string, guardians: Array, optimisticProposers?: Array | null, executionDelay: string, type: string } } | null, legacyAdminGovernances?: Array<{ id: string, token: { id: string, governance?: { id: string } | null } }> | null, tradingGovernance?: { id: string, name: string, version: string, votingDelay: string, votingPeriod: string, proposalThreshold: string, quorumVotes?: string | null, quorumNumerator?: string | null, quorumDenominator?: string | null, isOptimistic?: boolean | null, optimisticVetoDelay?: string | null, optimisticVetoPeriod?: string | null, optimisticVetoThreshold?: string | null, optimisticProposalThrottleCapacity?: string | null, optimisticSelectorRegistry?: string | null, optimisticProposers?: Array | null, timelock: { id: string, guardians: Array, optimisticProposers?: Array | null, executionDelay: string, type: string } } | null, token: { id: string, address: string, name: string, symbol: string, decimals: number, totalSupply: string, currentHolderCount: string, cumulativeHolderCount: string, transferCount: string, mintCount: string, burnCount: string, totalBurned: string, totalMinted: string }, stToken?: { id: string, currentDelegates: string, totalDelegates: string, delegatedVotesRaw: string, currentOptimisticDelegates: string, totalOptimisticDelegates: string, optimisticDelegatedVotesRaw: string, legacyGovernance: Array, token: { id: string, address: string, name: string, symbol: string, decimals: number, totalSupply: string, currentHolderCount: string, cumulativeHolderCount: string, transferCount: string, mintCount: string, burnCount: string, totalBurned: string, totalMinted: string }, underlying?: { name: string, symbol: string, address: string, decimals: number } | null, governance?: { id: string, name: string, version: string, votingDelay: string, votingPeriod: string, proposalThreshold: string, quorumVotes?: string | null, quorumNumerator?: string | null, quorumDenominator?: string | null, isOptimistic?: boolean | null, optimisticVetoDelay?: string | null, optimisticVetoPeriod?: string | null, optimisticVetoThreshold?: string | null, optimisticProposalThrottleCapacity?: string | null, optimisticSelectorRegistry?: string | null, optimisticProposers?: Array | null, timelock: { id: string, guardians: Array, optimisticProposers?: Array | null, executionDelay: string, type: string } } | null, rewards: Array<{ rewardToken: { address: string, name: string, symbol: string, decimals: number } }> } | null } | null }; export type GetIndexDtfProposalsQueryVariables = Exact<{ governanceIds: Array | Scalars['ID']['input']; @@ -7449,7 +7685,7 @@ export type GetIndexDtfProposalGovernanceAddressesQueryVariables = Exact<{ }>; -export type GetIndexDtfProposalGovernanceAddressesQuery = { dtf?: { legacyAdmins: Array, legacyAuctionApprovers: Array, ownerGovernance?: { id: string } | null, tradingGovernance?: { id: string } | null, stToken?: { legacyGovernance: Array, governance?: { id: string } | null } | null } | null }; +export type GetIndexDtfProposalGovernanceAddressesQuery = { dtf?: { legacyAdmins: Array, legacyAuctionApprovers: Array, ownerGovernance?: { id: string } | null, tradingGovernance?: { id: string } | null, legacyAdminGovernances?: Array<{ id: string, token: { id: string, governance?: { id: string } | null } }> | null, stToken?: { legacyGovernance: Array, governance?: { id: string } | null } | null } | null }; export type IndexDtfProposalContractContextFragment = { id: string, proxyAdmin: string, legacyAdmins: Array, legacyAuctionApprovers: Array, ownerGovernance?: { id: string, optimisticSelectorRegistry?: string | null, timelock: { id: string } } | null, tradingGovernance?: { id: string, optimisticSelectorRegistry?: string | null, timelock: { id: string } } | null, stToken?: { id: string, legacyGovernance: Array, governance?: { id: string, optimisticSelectorRegistry?: string | null, timelock: { id: string } } | null } | null }; @@ -7531,7 +7767,7 @@ export type GetIndexDtfDirectoryQueryVariables = Exact<{ }>; -export type GetIndexDtfDirectoryQuery = { dtfs: Array<{ id: string, legacyAdmins: Array, legacyAuctionApprovers: Array, token: { symbol: string, name: string }, stToken?: { id: string, legacyGovernance: Array, governance?: { id: string } | null } | null, ownerGovernance?: { id: string } | null, tradingGovernance?: { id: string } | null }> }; +export type GetIndexDtfDirectoryQuery = { dtfs: Array<{ id: string, legacyAdmins: Array, legacyAuctionApprovers: Array, token: { symbol: string, name: string }, stToken?: { id: string, legacyGovernance: Array, governance?: { id: string } | null } | null, ownerGovernance?: { id: string } | null, tradingGovernance?: { id: string } | null, legacyAdminGovernances?: Array<{ id: string, token: { id: string, governance?: { id: string } | null } }> | null }> }; export type GetIndexDtfTopVotersQueryVariables = Exact<{ limit?: InputMaybe; @@ -7540,6 +7776,13 @@ export type GetIndexDtfTopVotersQueryVariables = Exact<{ export type GetIndexDtfTopVotersQuery = { delegates: Array<{ address: string, numberVotes: number, numberOptimisticVotes: number, delegatedVotesRaw: string, tokenHoldersRepresentedAmount: number, token: { id: string, token: { symbol: string, name: string, decimals: number }, underlying?: { id: string, symbol: string, name: string, decimals: number } | null } }> }; +export type GetIndexDtfVoteLockDependentsQueryVariables = Exact<{ + voteLock: Scalars['String']['input']; +}>; + + +export type GetIndexDtfVoteLockDependentsQuery = { dtfs: Array<{ id: string, token: { symbol: string, name: string }, ownerGovernance?: { token: { id: string }, timelock: { id: string } } | null, tradingGovernance?: { token: { id: string }, timelock: { id: string } } | null }> }; + export type GetIndexDtfVoteLockVaultsQueryVariables = Exact<{ limit?: InputMaybe; cursor?: InputMaybe; @@ -7636,6 +7879,7 @@ export const GetIndexDtfDocument = new TypedDocumentString(` query GetIndexDTF($id: ID!, $block: Block_height) { dtf(id: $id, block: $block) { id + version proxyAdmin timestamp deployer @@ -7647,6 +7891,9 @@ export const GetIndexDtfDocument = new TypedDocumentString(` mandate auctionDelay auctionLength + maxAuctionLength + tradeAllowlistEnabled + tradeTokenAllowlist auctionApprovers auctionLaunchers brandManagers @@ -7654,7 +7901,10 @@ export const GetIndexDtfDocument = new TypedDocumentString(` protocolRevenue governanceRevenue externalRevenue + selfRevenue feeRecipients + immutableFeeRecipients + folioFeeForSelf bidsEnabled trustedFillerRegistry trustedFillerEnabled @@ -7686,6 +7936,15 @@ export const GetIndexDtfDocument = new TypedDocumentString(` } } legacyAdmins + legacyAdminGovernances { + id + token { + id + governance { + id + } + } + } tradingGovernance { id name @@ -7897,6 +8156,15 @@ export const GetIndexDtfProposalGovernanceAddressesDocument = new TypedDocumentS id } legacyAdmins + legacyAdminGovernances { + id + token { + id + governance { + id + } + } + } legacyAuctionApprovers stToken { governance { @@ -8260,6 +8528,15 @@ export const GetIndexDtfDirectoryDocument = new TypedDocumentString(` id } legacyAdmins + legacyAdminGovernances { + id + token { + id + governance { + id + } + } + } legacyAuctionApprovers } } @@ -8294,6 +8571,36 @@ export const GetIndexDtfTopVotersDocument = new TypedDocumentString(` } } `) as unknown as TypedDocumentString; +export const GetIndexDtfVoteLockDependentsDocument = new TypedDocumentString(` + query GetIndexDtfVoteLockDependents($voteLock: String!) { + dtfs( + first: 1000 + where: {or: [{stToken: $voteLock}, {ownerGovernance_: {token: $voteLock}}, {tradingGovernance_: {token: $voteLock}}]} + ) { + id + token { + symbol + name + } + ownerGovernance { + token { + id + } + timelock { + id + } + } + tradingGovernance { + token { + id + } + timelock { + id + } + } + } +} + `) as unknown as TypedDocumentString; export const GetIndexDtfVoteLockVaultsDocument = new TypedDocumentString(` query GetIndexDtfVoteLockVaults($limit: Int = 1000, $cursor: ID = "") { stakingTokens( diff --git a/packages/sdk/src/index-dtf/subgraph/dtf.graphql b/packages/sdk/src/index-dtf/subgraph/dtf.graphql index 34e03f2..bc398b7 100644 --- a/packages/sdk/src/index-dtf/subgraph/dtf.graphql +++ b/packages/sdk/src/index-dtf/subgraph/dtf.graphql @@ -1,6 +1,7 @@ query GetIndexDTF($id: ID!, $block: Block_height) { dtf(id: $id, block: $block) { id + version proxyAdmin timestamp deployer @@ -12,6 +13,9 @@ query GetIndexDTF($id: ID!, $block: Block_height) { mandate auctionDelay auctionLength + maxAuctionLength + tradeAllowlistEnabled + tradeTokenAllowlist auctionApprovers auctionLaunchers brandManagers @@ -19,7 +23,10 @@ query GetIndexDTF($id: ID!, $block: Block_height) { protocolRevenue governanceRevenue externalRevenue + selfRevenue feeRecipients + immutableFeeRecipients + folioFeeForSelf bidsEnabled trustedFillerRegistry trustedFillerEnabled @@ -51,6 +58,15 @@ query GetIndexDTF($id: ID!, $block: Block_height) { } } legacyAdmins + legacyAdminGovernances { + id + token { + id + governance { + id + } + } + } tradingGovernance { id name @@ -253,6 +269,15 @@ query GetIndexDtfProposalGovernanceAddresses($dtfId: ID!) { id } legacyAdmins + legacyAdminGovernances { + id + token { + id + governance { + id + } + } + } legacyAuctionApprovers stToken { governance { @@ -612,6 +637,15 @@ query GetIndexDtfDirectory($limit: Int = 1000, $cursor: ID = "") { id } legacyAdmins + legacyAdminGovernances { + id + token { + id + governance { + id + } + } + } legacyAuctionApprovers } } @@ -640,6 +674,41 @@ query GetIndexDtfTopVoters($limit: Int = 20) { } } +query GetIndexDtfVoteLockDependents($voteLock: String!) { + dtfs( + first: 1000 + where: { + or: [ + { stToken: $voteLock } + { ownerGovernance_: { token: $voteLock } } + { tradingGovernance_: { token: $voteLock } } + ] + } + ) { + id + token { + symbol + name + } + ownerGovernance { + token { + id + } + timelock { + id + } + } + tradingGovernance { + token { + id + } + timelock { + id + } + } + } +} + query GetIndexDtfVoteLockVaults($limit: Int = 1000, $cursor: ID = "") { stakingTokens(first: $limit, orderBy: id, orderDirection: asc, where: { id_gt: $cursor }) { id diff --git a/packages/sdk/src/index-dtf/version-registry.test.ts b/packages/sdk/src/index-dtf/version-registry.test.ts new file mode 100644 index 0000000..f3d0216 --- /dev/null +++ b/packages/sdk/src/index-dtf/version-registry.test.ts @@ -0,0 +1,80 @@ +import { keccak256, toBytes, zeroAddress } from "viem"; +import { describe, expect, it, vi } from "vitest"; + +import type { DtfClient } from "@/client"; + +import { folioVersionRegistryAbi } from "@/index-dtf/abis/folio-version-registry.generated"; +import { + getIndexDtfLatestVersion, + getIndexDtfVersionDeployment, + getIndexDtfVersionHash, + INDEX_DTF_VERSION_REGISTRY_ADDRESS, +} from "@/index-dtf/version-registry"; + +const DEPLOYER = "0x4D201a6e5BF975E2CEE9e5cbDfc803C0Ff122073"; +const IMPLEMENTATION = "0x10E8ADc336e9D3d18E52567ecd0B151A54ac8E3D"; +const V5_HASH = keccak256(toBytes("5.0.0")); + +describe("Folio version registry reads", () => { + it("hashes versions the way the registry does", () => { + expect(getIndexDtfVersionHash("5.0.0")).toBe(V5_HASH); + }); + + it("reads getLatestVersion from the chain's registry by default", async () => { + const readContract = vi.fn(async () => [V5_HASH, "5.0.0", DEPLOYER.toLowerCase(), false]); + const client = { viem: { readContract } } as unknown as DtfClient; + + await expect(getIndexDtfLatestVersion(client, { chainId: 56 })).resolves.toEqual({ + versionHash: V5_HASH, + version: "5.0.0", + deployer: DEPLOYER, + deprecated: false, + }); + expect(readContract).toHaveBeenCalledWith({ + address: INDEX_DTF_VERSION_REGISTRY_ADDRESS[56], + abi: folioVersionRegistryAbi, + functionName: "getLatestVersion", + chainId: 56, + blockNumber: undefined, + }); + }); + + it("reads one registered version's deployer, implementation and deprecation", async () => { + const multicall = vi.fn(async (_request: { readonly contracts: readonly unknown[] }) => [ + { status: "success", result: DEPLOYER }, + { status: "success", result: IMPLEMENTATION }, + { status: "success", result: true }, + ]); + const client = { viem: { getPublicClient: vi.fn(() => ({ multicall })) } } as unknown as DtfClient; + + await expect( + getIndexDtfVersionDeployment(client, { chainId: 1, version: "5.0.0", registry: DEPLOYER, blockNumber: 3n }), + ).resolves.toEqual({ + version: "5.0.0", + versionHash: V5_HASH, + deployer: DEPLOYER, + implementation: IMPLEMENTATION, + deprecated: true, + }); + expect(multicall.mock.calls[0]?.[0]).toMatchObject({ + allowFailure: true, + blockNumber: 3n, + contracts: [ + { address: DEPLOYER, functionName: "deployments", args: [V5_HASH] }, + { address: DEPLOYER, functionName: "getImplementationForVersion", args: [V5_HASH] }, + { address: DEPLOYER, functionName: "isDeprecated", args: [V5_HASH] }, + ], + }); + }); + + it("returns null for a version that was never registered", async () => { + const multicall = vi.fn(async () => [ + { status: "success", result: zeroAddress }, + { status: "failure", error: new Error("revert") }, + { status: "success", result: false }, + ]); + const client = { viem: { getPublicClient: vi.fn(() => ({ multicall })) } } as unknown as DtfClient; + + await expect(getIndexDtfVersionDeployment(client, { chainId: 8453, version: "9.9.9" })).resolves.toBeNull(); + }); +}); diff --git a/packages/sdk/src/index-dtf/version-registry.ts b/packages/sdk/src/index-dtf/version-registry.ts new file mode 100644 index 0000000..010e279 --- /dev/null +++ b/packages/sdk/src/index-dtf/version-registry.ts @@ -0,0 +1,111 @@ +import { getAddress, keccak256, toBytes, zeroAddress, type Address, type Hex } from "viem"; + +import type { DtfClient } from "@/client"; +import type { SupportedChainId } from "@/config"; + +import { folioVersionRegistryAbi } from "@/index-dtf/abis/folio-version-registry.generated"; + +/** `FolioVersionRegistry` per chain, as returned by each chain's `FolioDeployer.versionRegistry()`. */ +export const INDEX_DTF_VERSION_REGISTRY_ADDRESS = { + 1: "0xA665b273997F70b647B66fa7Ed021287544849dB", + 8453: "0xA665b273997F70b647B66fa7Ed021287544849dB", + 56: "0x79A4E963378AE34fC6c796a24c764322fC6c9390", +} as const satisfies Record; + +export type IndexDtfLatestVersion = { + readonly versionHash: Hex; + readonly version: string; + readonly deployer: Address; + readonly deprecated: boolean; +}; + +export type IndexDtfVersionDeployment = { + readonly version: string; + readonly versionHash: Hex; + readonly deployer: Address; + readonly implementation: Address; + readonly deprecated: boolean; +}; + +export type GetIndexDtfLatestVersionParams = { + readonly chainId: SupportedChainId; + readonly registry?: Address; + readonly blockNumber?: bigint; +}; + +export type GetIndexDtfVersionDeploymentParams = GetIndexDtfLatestVersionParams & { + readonly version: string; +}; + +/** The registry keys versions by `keccak256(abi.encodePacked(version))`. */ +export function getIndexDtfVersionHash(version: string): Hex { + return keccak256(toBytes(version)); +} + +/** Reads `getLatestVersion()`: the newest registered Folio release and its deployer. */ +export async function getIndexDtfLatestVersion( + client: DtfClient, + params: GetIndexDtfLatestVersionParams, +): Promise { + const [versionHash, version, deployer, deprecated] = await client.viem.readContract({ + address: getRegistryAddress(params), + abi: folioVersionRegistryAbi, + functionName: "getLatestVersion", + chainId: params.chainId, + blockNumber: params.blockNumber, + }); + + return { versionHash, version, deployer: getAddress(deployer), deprecated }; +} + +/** + * Reads one registered release: deployer, Folio implementation and deprecation flag. + * Returns `null` when the version was never registered on that chain. + */ +export async function getIndexDtfVersionDeployment( + client: DtfClient, + params: GetIndexDtfVersionDeploymentParams, +): Promise { + const registry = getRegistryAddress(params); + const versionHash = getIndexDtfVersionHash(params.version); + const [deployer, implementation, deprecated] = await client.viem.getPublicClient(params.chainId).multicall({ + allowFailure: true, + blockNumber: params.blockNumber, + contracts: [ + { address: registry, abi: folioVersionRegistryAbi, functionName: "deployments", args: [versionHash] }, + { + address: registry, + abi: folioVersionRegistryAbi, + functionName: "getImplementationForVersion", + args: [versionHash], + }, + { address: registry, abi: folioVersionRegistryAbi, functionName: "isDeprecated", args: [versionHash] }, + ], + }); + + // `deployments` is a mapping getter and cannot revert on a real registry; a failure is a bad registry, not an absence. + if (deployer.status !== "success") { + throw deployer.error; + } + if (deployer.result === zeroAddress) { + return null; + } + if (implementation.status !== "success") { + throw implementation.error; + } + if (deprecated.status !== "success") { + throw deprecated.error; + } + + return { + version: params.version, + versionHash, + deployer: getAddress(deployer.result), + implementation: getAddress(implementation.result), + deprecated: deprecated.result, + }; +} + +function getRegistryAddress(params: { readonly chainId: SupportedChainId; readonly registry?: Address }): Address { + return getAddress(params.registry ?? INDEX_DTF_VERSION_REGISTRY_ADDRESS[params.chainId]); +} diff --git a/packages/sdk/src/index-dtf/write-version.ts b/packages/sdk/src/index-dtf/write-version.ts new file mode 100644 index 0000000..e2477ee --- /dev/null +++ b/packages/sdk/src/index-dtf/write-version.ts @@ -0,0 +1,28 @@ +import { dtfIndexAbi as indexDtfV5Abi } from "@/index-dtf/abis/dtf-index-abi"; +import { folioV6Abi as indexDtfV6Abi } from "@/index-dtf/abis/folio-v6.generated"; +import { SdkError } from "@/lib/errors"; + +export const indexDtfV5WriteAbi = indexDtfV5Abi; +export const indexDtfV6WriteAbi = indexDtfV6Abi; + +export type IndexDtfWriteVersion = "5.0.0" | "6.0.0"; + +const INDEX_DTF_WRITE_VERSIONS: readonly IndexDtfWriteVersion[] = ["5.0.0", "6.0.0"]; + +/** Runtime guard for every write/calldata builder: v4 and unknown releases never get v5 bytes by default. */ +export function assertIndexDtfWriteVersion(version: unknown): asserts version is IndexDtfWriteVersion { + if (!INDEX_DTF_WRITE_VERSIONS.includes(version as IndexDtfWriteVersion)) { + throw new SdkError({ + code: "INVALID_INPUT", + message: `Unsupported Index DTF version: ${String(version)}`, + meta: { version }, + }); + } +} + +/** The Folio ABI a write builder encodes against; asserts the version first. */ +export function getIndexDtfWriteAbi(version: IndexDtfWriteVersion) { + assertIndexDtfWriteVersion(version); + + return version === "6.0.0" ? indexDtfV6WriteAbi : indexDtfV5WriteAbi; +} diff --git a/packages/sdk/src/index.test.ts b/packages/sdk/src/index.test.ts index 336b11f..e15cad0 100644 --- a/packages/sdk/src/index.test.ts +++ b/packages/sdk/src/index.test.ts @@ -6,4 +6,15 @@ describe("SDK public surface", () => { it("does not export wallet-client creation helpers", () => { expect("createWalletClient" in sdk).toBe(false); }); + + it("exports the canonical generated Index DTF v6 ABIs", () => { + expect(sdk.folioArtifactAbi).toBe(sdk.folioV6Abi); + expect(sdk.folioV6Abi.some((entry) => entry.type === "function" && entry.name === "startRebalance")).toBe(true); + expect(sdk.folioDeployerV6Abi.some((entry) => entry.type === "function" && entry.name === "deployFolio")).toBe( + true, + ); + expect( + sdk.folioVersionRegistryAbi.some((entry) => entry.type === "function" && entry.name === "registerVersion"), + ).toBe(true); + }); }); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 5da7b25..4d23373 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -17,11 +17,7 @@ export { export type { GovernorProposalParams, GovernorProposalPayload, GovernorVoteParams } from "@/lib/governor-calls"; export { createDtfSdk } from "@/create-dtf-sdk"; export { SdkError, isSdkError } from "@/lib/errors"; -export { - default as dtfCatalog, - indexDtfs as indexDtfCatalog, - yieldDtfs as yieldDtfCatalog, -} from "@reserve-protocol/dtf-catalog"; +export { dtfCatalog, indexDtfs as indexDtfCatalog, yieldDtfs as yieldDtfCatalog } from "@reserve-protocol/dtf-catalog"; export type { DtfClient, DtfChainConfig, DtfClientConfig } from "@/client"; export type { DtfClientApi, @@ -88,6 +84,10 @@ export { buildIndexDtfDaoSettingsProposal, buildIndexDtfDeployFeeRecipients, buildIndexDtfSettingsProposal, + buildIndexDtfUpgradeToV6Calls, + buildIndexDtfUpgradeToV6Proposal, + INDEX_DTF_START_REBALANCE_SELECTOR, + upgradeSpell600Abi, CANCELLER_ROLE, DEFAULT_INDEX_DTF_DEPLOY_FLAGS, buildIndexDtfStartRebalance, @@ -114,7 +114,10 @@ export { dtfIndexStakingVaultOptimisticAbi, extractIndexDtfDeployedAddress, extractIndexDtfDeployedStakingTokenAddress, + folioDeployerV6Abi, folioArtifactAbi, + folioV6Abi, + folioVersionRegistryAbi, generateIndexDtfDeploymentNonce, getAllIndexDtfProposals, hashIndexDtfProposalDescription, @@ -128,9 +131,25 @@ export { getIndexDtfBasketUnitsFromShares, getIndexDtfBasketWithPrice, getIndexDtfDeployApprovalAmount, + getIndexDtfDeployerAddress, getIndexDtfActiveAuction, getIndexDtfBidQuote, getIndexDtfBidsEnabled, + getIndexDtfMaxAuctionLength, + getIndexDtfRebalanceNonce, + getIndexDtfSelfFee, + assertIndexDtfFeeRecipientTables, + INDEX_DTF_FEE_RECIPIENT_TOTAL, + INDEX_DTF_MAX_FEE_RECIPIENTS, + scaleIndexDtfFeeRecipients, + sortIndexDtfFeeRecipients, + getIndexDtfImmutableFeeRecipients, + getIndexDtfTradeAllowlist, + getIndexDtfIsTokenAllowlisted, + getIndexDtfLatestVersion, + getIndexDtfVersionDeployment, + getIndexDtfVersionHash, + INDEX_DTF_VERSION_REGISTRY_ADDRESS, getIndexDtfBrand, getIndexDtfCompletedRebalance, getIndexDtfCompletedRebalances, @@ -150,6 +169,7 @@ export { getIndexDtfHolders, getIndexDtfIssuanceState, getIndexDtfLegacyVoteLocks, + getIndexDtfVoteLockDependents, getIndexDtfLatestAuction, getIndexDtfMaxSafeRebalancePercent, getIndexDtfMandate, @@ -221,6 +241,7 @@ export { indexDtfV5WriteAbi, indexDtfV6WriteAbi, INDEX_DTF_DEPLOYER_ADDRESS, + INDEX_DTF_V6_DEPLOYER_ADDRESS, INDEX_DTF_GOVERNANCE_DEPLOYER_ADDRESS, listIndexDtfs, OPTIMISTIC_PROPOSER_ROLE, @@ -336,6 +357,9 @@ export { timelockAbi, unstakingManagerAbi, upgradeSpellProposalAbi, + INDEX_DTF_UPGRADE_SPELL_6_0_0_ADDRESS, + governanceSpell09182026Abi, + INDEX_DTF_GOVERNANCE_SPELL_09_18_2026_ADDRESS, } from "@/index-dtf/index"; export { basketHandlerAbi, @@ -450,7 +474,11 @@ export { getAccountPortfolioTransactions, } from "@/client/api/portfolio"; export { getTokenData, getTokenPrices, getTokenVolatilities, getTokensData } from "@/lib/tokens"; -export type { IndexDtfRef } from "@/index-dtf/index"; +export type { + IndexDtfRef, + PrepareIndexDtfEndRebalanceParams, + PrepareIndexDtfOpenAuctionParams, +} from "@/index-dtf/index"; export type * from "@/index-dtf/dtf/basket/index"; export type * from "@/index-dtf/governance/propose/index"; export type * from "@/index-dtf/index"; diff --git a/packages/sdk/src/types/governance.ts b/packages/sdk/src/types/governance.ts index d3a43ec..bd40d11 100644 --- a/packages/sdk/src/types/governance.ts +++ b/packages/sdk/src/types/governance.ts @@ -547,3 +547,16 @@ export type IndexDtfGovernanceActivity = | IndexDtfGovernanceVoteActivity | IndexDtfGovernanceProposalActivity | IndexDtfGovernanceStakeActivity; + +export type GetIndexDtfVoteLockDependentsParams = { + readonly chainId: SupportedChainId; + readonly voteLock: Address; +}; + +export type IndexDtfVoteLockDependent = { + readonly address: Address; + readonly symbol: string; + readonly name: string; + // True while the DTF's owner timelock (voting with this vote lock) still holds the Folio admin role. + readonly governedByVoteLock: boolean; +}; diff --git a/packages/sdk/src/types/index-dtf.ts b/packages/sdk/src/types/index-dtf.ts index aac57fa..acf2a58 100644 --- a/packages/sdk/src/types/index-dtf.ts +++ b/packages/sdk/src/types/index-dtf.ts @@ -23,6 +23,10 @@ export type Fees = { readonly tvlFee: Amount; readonly annualizedTvlFee: number; readonly recipients: FeeRecipients; + /** Folio 6.0: recipients governance cannot remove; empty before 6.0. */ + readonly immutableRecipients: FeeRecipients; + /** Folio 6.0: D18 share of the recipient pool kept for holders; zero before 6.0. */ + readonly selfFee: Amount; }; export type IndexDtfGovernance = { @@ -50,6 +54,13 @@ export type VoteLockVault = { }; }; +// A Folio admin governor replaced by a governance migration, with the vote-lock vault it voted with. +export type LegacyAdminGovernance = { + readonly governance: Address; + readonly voteLock: Address; + readonly voteLockGovernance?: Address; +}; + export type IndexDtfAdminRoles = { // The protocol can expose multiple admin role members. In production this is // normally one timelock/admin. `primary` is what product workflows usually @@ -59,6 +70,7 @@ export type IndexDtfAdminRoles = { // Previous admin authorities are needed to resolve/display historical // proposals after governance has changed. readonly legacy: readonly Address[]; + readonly legacyGovernances: readonly LegacyAdminGovernance[]; }; export type IndexDtfRoles = { @@ -85,7 +97,11 @@ export type PriceControl = 0 | 1 | 2; export type IndexDtfRebalanceConfig = { readonly auctionDelay: number; + /** Folio 6.0 mirrors `maxAuctionLength` here so existing readers keep working. */ readonly auctionLength: number; + readonly maxAuctionLength?: number; + /** Folio 6.0 trade allowlist; absent before 6.0. */ + readonly tradeAllowlist?: { readonly enabled: boolean; readonly tokens: readonly Address[] }; readonly bidsEnabled?: boolean; readonly trustedFillerRegistry?: Address; readonly trustedFillerEnabled?: boolean; @@ -94,11 +110,15 @@ export type IndexDtfRebalanceConfig = { readonly priceControl: PriceControl; }; +/** Fee shares paid, as raw 18-decimal share amounts from the subgraph (display-class numbers). */ export type Financials = { + /** protocol + governance + external + self. */ readonly totalRevenue: number; readonly protocolRevenue: number; readonly governanceRevenue: number; readonly externalRevenue: number; + /** Folio 6.0 self fee kept for holders; 0 before 6.0 and for DTFs the subgraph indexed before 1.11.1. */ + readonly selfRevenue: number; }; export type IndexDtfPriceBasketToken = { @@ -240,6 +260,8 @@ export type IndexDtfFull = IndexDtf & { export type IndexDtf = { readonly id: Address; readonly chainId: SupportedChainId; + /** Folio release read from `version()` by the subgraph (1.11+); absent while a grafted DTF's field is still null. */ + readonly version?: string; readonly token: TokenWithSnapshot; readonly mandate: string; readonly createdAt: number; diff --git a/packages/sdk/src/yield-dtf/apy.test.ts b/packages/sdk/src/yield-dtf/apy.test.ts index c3506e2..2d8d367 100644 --- a/packages/sdk/src/yield-dtf/apy.test.ts +++ b/packages/sdk/src/yield-dtf/apy.test.ts @@ -7,11 +7,11 @@ afterEach(() => { }); describe("computeYieldDtfApy", () => { - // eUSD-shaped example: 4% blended yield, 60/40 split, 2x staker leverage. + // 3.4% blended yield, 60/40 revenue split, 2x staker leverage. const params = { basket: [ - { symbol: "saUSDC", share: 50 }, - { symbol: "wcusdcv3", share: 50 }, + { symbol: "saUSDC", share: 80 }, + { symbol: "wcusdcv3", share: 20 }, ], collateralYields: { sausdc: 3, wcusdcv3: 5 }, revenueSplit: { holders: 60, stakers: 40 }, @@ -19,21 +19,33 @@ describe("computeYieldDtfApy", () => { stakedUsd: 10_000_000, }; - it("weights collateral yields by basket share", () => { - expect(computeYieldDtfApy(params).basket).toBe(4); + it.each([ + { firstShare: 90, secondShare: 10, expected: 3.2 }, + { firstShare: 10, secondShare: 90, expected: 4.8 }, + { firstShare: 100, secondShare: 0, expected: 3 }, + ])("weights collateral yields for a $firstShare/$secondShare basket", ({ firstShare, secondShare, expected }) => { + expect( + computeYieldDtfApy({ + ...params, + basket: [ + { symbol: "saUSDC", share: firstShare }, + { symbol: "wcusdcv3", share: secondShare }, + ], + }).basket, + ).toBeCloseTo(expected); }); it("splits holder APY by revenue share", () => { - expect(computeYieldDtfApy(params).holders).toBeCloseTo(2.4); + expect(computeYieldDtfApy(params).holders).toBeCloseTo(2.04); }); it("levers staker APY by supply over stake", () => { - // 4% basket on 20M supply -> 800k revenue; 40% of that on 10M stake = 3.2%. - expect(computeYieldDtfApy(params).stakers).toBeCloseTo(3.2); + // 3.4% basket on 20M supply -> 680k revenue; 40% on 10M stake = 2.72%. + expect(computeYieldDtfApy(params).stakers).toBeCloseTo(2.72); }); it("does not lever stakers when nothing is staked", () => { - expect(computeYieldDtfApy({ ...params, stakedUsd: 0 }).stakers).toBeCloseTo(1.6); + expect(computeYieldDtfApy({ ...params, stakedUsd: 0 }).stakers).toBeCloseTo(1.36); }); it("matches symbols case-insensitively and skips unknown collateral", () => { @@ -50,10 +62,8 @@ describe("computeYieldDtfApy", () => { }); describe("COLLATERAL_POOL_MAP", () => { - it("has unique pool ids per chain and lowercase symbols", () => { + it("uses lowercase collateral symbols", () => { for (const map of Object.values(COLLATERAL_POOL_MAP)) { - const ids = Object.keys(map); - expect(new Set(ids).size).toBe(ids.length); for (const symbol of Object.values(map)) { expect(symbol).toBe(symbol.toLowerCase()); } diff --git a/packages/sdk/src/yield-dtf/calls.test.ts b/packages/sdk/src/yield-dtf/calls.test.ts index 5f4a6cf..a230bcd 100644 --- a/packages/sdk/src/yield-dtf/calls.test.ts +++ b/packages/sdk/src/yield-dtf/calls.test.ts @@ -1,3 +1,4 @@ +import { decodeFunctionData, erc20Abi } from "viem"; import { describe, expect, it } from "vitest"; import { prepareYieldDtfVote } from "@/yield-dtf/governance"; @@ -97,8 +98,19 @@ describe("yield DTF issuance calls", () => { expect(plan.type).toBe("approval-required"); if (plan.type !== "approval-required") return; expect(plan.approvals).toHaveLength(2); - expect(plan.approvals[0]!.contract.args).toEqual([DTF, 5n]); - expect(plan.approvals[1]!.contract.args).toEqual([DTF, 7n]); + const expectedApprovals = [ + { token: ST_TOKEN, amount: 5n }, + { token: ACCOUNT, amount: 7n }, + ]; + for (const [index, expected] of expectedApprovals.entries()) { + const approval = plan.approvals[index]!; + expect(approval).toMatchObject({ to: expected.token, chainId: 1, value: 0n }); + expect(decodeFunctionData({ abi: erc20Abi, data: approval.data })).toEqual({ + functionName: "approve", + args: [DTF, expected.amount], + }); + } + expect(plan.call).toMatchObject({ to: DTF, chainId: 1, value: 0n }); expect(plan.call.data).toBe("0xcc872b66" + PAD("0x2a")); }); diff --git a/packages/sdk/src/yield-dtf/governance.test.ts b/packages/sdk/src/yield-dtf/governance.test.ts index 7e02f92..409d503 100644 --- a/packages/sdk/src/yield-dtf/governance.test.ts +++ b/packages/sdk/src/yield-dtf/governance.test.ts @@ -85,16 +85,28 @@ describe("getYieldDtfGovernance", () => { }); describe("getYieldDtfProposal", () => { + it("uses the authoritative governor state when the subgraph still reports ACTIVE", async () => { + const readContract = vi.fn(async (): Promise => 7); + const client = createProposalClient(readContract); + + const proposal = await getYieldDtfProposal(client, { chainId: 1, proposalId: "42" }); + + expect(proposal.state).toBe("EXECUTED"); + expect(readContract).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ address: GOVERNOR, chainId: 1, functionName: "state", args: [42n] }), + ); + }); + it("falls back to the subgraph state for proposals unknown to the current governor", async () => { - const client = createProposalClient( - new BaseError("readContract failed", { + const client = createProposalClient(async () => { + throw new BaseError("readContract failed", { cause: new ContractFunctionRevertedError({ abi: [], functionName: "state", message: "Governor: unknown proposal id", }), - }), - ); + }); + }); const proposal = await getYieldDtfProposal(client, { chainId: 1, proposalId: "42" }); @@ -103,7 +115,9 @@ describe("getYieldDtfProposal", () => { it("rethrows non-unknown-proposal state read failures", async () => { const error = new BaseError("RPC request failed"); - const client = createProposalClient(error); + const client = createProposalClient(async () => { + throw error; + }); await expect(getYieldDtfProposal(client, { chainId: 1, proposalId: "42" })).rejects.toBe(error); }); @@ -358,7 +372,7 @@ function createProposalsClient( } as unknown as DtfClient; } -function createProposalClient(readError: Error): DtfClient { +function createProposalClient(readContract: () => Promise): DtfClient { return { subgraph: { queryYield: vi.fn(async () => ({ @@ -397,9 +411,7 @@ function createProposalClient(readError: Error): DtfClient { })), }, viem: { - readContract: vi.fn(async () => { - throw readError; - }), + readContract, }, } as unknown as DtfClient; } diff --git a/packages/sdk/src/yield-dtf/propose.test.ts b/packages/sdk/src/yield-dtf/propose.test.ts index 975fb8e..094d0f7 100644 --- a/packages/sdk/src/yield-dtf/propose.test.ts +++ b/packages/sdk/src/yield-dtf/propose.test.ts @@ -94,12 +94,15 @@ describe("yield DTF proposal builders", () => { const calls = [ prepareYieldDtfSetPrimeBasket({ chainId: 1, address: MAIN, erc20s: [ERC20], targetAmounts: [10n ** 18n] }), prepareYieldDtfRefreshBasket({ chainId: 1, address: MAIN }), + prepareYieldDtfGrantRole({ chainId: 1, address: ACCOUNT, role: "owner", account: GOVERNOR }), ]; const payload = toYieldDtfProposalPayload(GOVERNOR, "Basket change", calls); - expect(payload.governor).toBe(GOVERNOR); - expect(payload.targets).toEqual([MAIN, MAIN]); - expect(payload.calldatas).toHaveLength(2); - expect(payload.description).toBe("Basket change"); + expect(payload).toEqual({ + governor: GOVERNOR, + targets: [MAIN, MAIN, ACCOUNT], + calldatas: [calls[0]!.data, calls[1]!.data, calls[2]!.data], + description: "Basket change", + }); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17569b6..bfce5dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,8 +150,8 @@ importers: specifier: workspace:* version: link:../dtf-catalog '@reserve-protocol/dtf-rebalance-lib': - specifier: ^3.2.1 - version: 3.2.1(decimal.js-light@2.5.1) + specifier: ^3.3.1 + version: 3.3.1(decimal.js-light@2.5.1) decimal.js-light: specifier: ^2.5.1 version: 2.5.1 @@ -1464,8 +1464,9 @@ packages: '@repeaterjs/repeater@3.0.6': resolution: {integrity: sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==} - '@reserve-protocol/dtf-rebalance-lib@3.2.1': - resolution: {integrity: sha512-QfxMA/rLRYlgQHJCULzNHAHxXSPGER4TcN/4r6o5qjJklC4fcSHuxaE1DUJW5eR+8zTXwuZil5JJNHTEBPco8Q==} + '@reserve-protocol/dtf-rebalance-lib@3.3.1': + resolution: {integrity: sha512-Bftvz3OV7oD1bMTJ2gguq1XMh2qUgOzUWPo7VDY6KbT4kAVdBdCBbmN1D7zMSeMSAFrQV+/jWuuntRjTlmGkqA==} + engines: {node: '>=20'} peerDependencies: decimal.js-light: ^2.5.1 @@ -6652,7 +6653,7 @@ snapshots: '@repeaterjs/repeater@3.0.6': {} - '@reserve-protocol/dtf-rebalance-lib@3.2.1(decimal.js-light@2.5.1)': + '@reserve-protocol/dtf-rebalance-lib@3.3.1(decimal.js-light@2.5.1)': dependencies: decimal.js-light: 2.5.1 diff --git a/scripts/check-doc-links.mjs b/scripts/check-doc-links.mjs index 8a44676..9a44d86 100644 --- a/scripts/check-doc-links.mjs +++ b/scripts/check-doc-links.mjs @@ -71,6 +71,9 @@ function collectMarkdown(path) { const child = join(path, entry.name); if (entry.isDirectory()) { + if (entry.name === "node_modules") { + continue; + } if (path === "packages" && !existsSync(join(child, "README.md"))) { continue; } diff --git a/scripts/check-graphql-codegen.mjs b/scripts/check-graphql-codegen.mjs index 93e9536..617f608 100644 --- a/scripts/check-graphql-codegen.mjs +++ b/scripts/check-graphql-codegen.mjs @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -15,6 +15,15 @@ const result = spawnSync(pnpm, ["--filter", "@reserve-protocol/sdk", "graphql:co stdio: "inherit", }); +const changedFiles = generatedFiles.filter((file) => before.get(file) !== read(file)); +// A check must not rewrite the checked-in types (e.g. back to an older prod schema): restore them on every path, +// including a codegen run that failed after writing one output. +for (const file of changedFiles) { + const content = before.get(file); + if (content === undefined) rmSync(resolve(root, file), { force: true }); + else writeFileSync(resolve(root, file), content); +} + if (result.error) { throw result.error; } @@ -23,14 +32,17 @@ if (result.status !== 0) { process.exit(result.status ?? 1); } -const changedFiles = generatedFiles.filter((file) => before.get(file) !== read(file)); - if (changedFiles.length > 0) { - console.error(`GraphQL generated outputs changed:\n${changedFiles.join("\n")}`); - console.error("Review the generated updates, then rerun this check."); + console.error(`GraphQL generated outputs differ from the configured schemas:\n${changedFiles.join("\n")}`); + console.error( + "Run `pnpm graphql:codegen` and review the diff. Before a subgraph release is promoted to prod, point the index " + + "schema at the candidate with INDEX_DTF_SUBGRAPH_SCHEMA=<.../dtf-index-base//gn>.", + ); process.exitCode = 1; } else { - console.log("GraphQL generated outputs are current."); + console.log( + `GraphQL generated outputs are current (index schema: ${process.env.INDEX_DTF_SUBGRAPH_SCHEMA || "prod"}).`, + ); } function read(file) {