diff --git a/.changeset/fixed-rate-offer-chain.md b/.changeset/fixed-rate-offer-chain.md new file mode 100644 index 000000000..6937ce36c --- /dev/null +++ b/.changeset/fixed-rate-offer-chain.md @@ -0,0 +1,5 @@ +--- +"@morpho-org/midnight-sdk": minor +--- + +Add `OfferChainUtils.buildFixedRateOfferChain` and `getMaxFixedRateOfferChainEndTimestamp` so the markets app can build adjacent, grouped offers that keep a maker's displayed fixed rate stable across a long selected window. diff --git a/.changeset/quiet-midnight-flows.md b/.changeset/quiet-midnight-flows.md new file mode 100644 index 000000000..2f40ecaae --- /dev/null +++ b/.changeset/quiet-midnight-flows.md @@ -0,0 +1,15 @@ +--- +"@morpho-org/morpho-sdk": minor +--- + +Add Midnight action flows under `client.morpho.midnight(chainId)`, expose Midnight SDK API helpers through `morpho-sdk/midnight-api` and shared ABI/constant/error/utility entrypoints, and expose pure Midnight transaction builders for fixed-rate taker, maker, redeem, repay/withdraw, and cancel flows. + +The Midnight entity returns lazy action outputs with `getRequirements()` and synchronous `buildTx(...)` methods, matching the existing `morpho-sdk` action pattern while accepting fixed-rate API quote takeable offers directly. UI labels, rate display logic, and offer-chain presentation stay on the integrator side. + +Midnight market transaction builders are synchronous and consume caller-provided `marketData` state, with `redeem` also consuming caller-provided `positionData`. Maker-offer action builders consume caller-provided `offersData` from `getOffersData(...)`, which creates the tree from the same entries accepted by `Tree.create(...)` and runs mempool validation. `getMarketData(...)`, block-accrued `getPositionData(...)`, and `getOffersData(...)` remain async helpers so integrators can prepare state once, compose UI/validation around it, and then build transactions without hidden reads. + +Midnight Bundles calls support ERC2612 and Permit2 token permits through the same `supportSignature` / `useSimplePermit` requirement flow as Blue, while preserving `PermitKind.None` for approval-based execution. + +Borrow-side flows are explicit: `takeBorrow` and `makeBorrow` borrow without supplying collateral, while `supplyCollateralTakeBorrow` and `supplyCollateralMakeBorrow` perform collateral-supply plus borrow flows. Public maker flows are exposed through named synchronous actions such as `makeLend`, `makeBorrow`, and `supplyCollateralMakeBorrow`; they accept precomputed `offersData` prepared from one or more standalone offers or groups. Maker submit metadata exposes all submitted group ids, and the ratifier helpers enforce that the submitted tree uses one ratifier. + +Named take transaction builders validate that their takeable offers match the expected maker side, and named maker entity flows validate that prepared maker trees match the expected maker side. `getOffersData(...)` stays side-agnostic so callers can prepare any valid tree. diff --git a/docs/tibs/TIB-2026-05-20-midnight-sdk-utilities.md b/docs/tibs/TIB-2026-05-20-midnight-sdk-utilities.md index 0f69aeaf4..bc6bea268 100644 --- a/docs/tibs/TIB-2026-05-20-midnight-sdk-utilities.md +++ b/docs/tibs/TIB-2026-05-20-midnight-sdk-utilities.md @@ -399,6 +399,7 @@ Expose deterministic library methods: - `TickLib.snapPriceToTick` - `TickLib.rateToPrice` - `TickLib.tickToRate` +- `TickLib.tickToApr` - `TakeAmountsLib.buyerAssetsToUnits` - `TakeAmountsLib.sellerAssetsToUnits` - `TakeAmountsLib.toUnits` for SDK-only generic unit conversion convenience @@ -412,6 +413,9 @@ SDK-only derived helpers should live beside the domain they describe: - `MarketUtils.getLiquidationIncentiveFactor` with an explicit liquidation cursor - `MarketUtils.getSettlementFee` - `PositionUtils.accrueInterest` +- `OfferUtils.getPrice` +- `OfferUtils.getRate` +- `OfferUtils.getApr` - `OfferUtils.getOfferExpiry` The pure helpers that mirror `TakeAmountsLib` should accept `settlementFee` as an explicit input instead of reading the chain. Fetching settlement fee remains a boundary concern; fetch helpers that compute it should accept `timeToMaturity` directly so callers do not confuse wall-clock time with Solidity's `block.timestamp`. diff --git a/docs/tibs/TIB-2026-06-03-midnight-action-output-interface.md b/docs/tibs/TIB-2026-06-03-midnight-action-output-interface.md new file mode 100644 index 000000000..cab866b1d --- /dev/null +++ b/docs/tibs/TIB-2026-06-03-midnight-action-output-interface.md @@ -0,0 +1,952 @@ +# TIB-2026-06-03: Midnight action flow implementation + +| Field | Value | +| ---------- | ------------------------------------ | +| **Status** | Proposed | +| **Date** | 2026-06-03 | +| **Author** | Romain / Carapulse draft | +| **Scope** | Package: `morpho-sdk` / Midnight SDK | + +--- + +## Context + +This TIB specifies the implementation of Midnight action flows in `morpho-sdk`. The source behavior is the markets app (`morpho-apps/apps/markets-app`): its home-made action builders already encode the protocol paths, requirement ordering, token-pull policy, ratifier selection, and mempool submission behavior future integrators need. The SDK should lift that protocol logic into reusable Midnight entity / action flows, while keeping the markets app migration as close as possible to an adapter swap. + +The markets app is also the compatibility target. To minimize its diff, the SDK keeps the lazy action output shape already used by existing `morpho-sdk` action flows and widens only the requirement list and maker-offer signature arguments needed by the current app flows: + +```ts +{ + getRequirements: () => Promise; + buildTx: ( + requirementSignatures?: + | MidnightOfferRootSignature + | readonly MidnightOfferRootSignature[], + ) => Readonly>; +} +``` + +The concrete implementation is still MarketV1 / vault oriented: + +- `buildTx(...)` returns one final `Transaction`. +- `getRequirements(...)` returns prerequisite approval / permit / authorization items. +- `Requirement` currently means only a signature requirement (`permit` / `permit2`). +- Transaction requirements are raw `Transaction` or `Transaction` values. +- The shared `getRequirements(...)` helper is tuned for `bundler3.generalAdapter1` as spender. + +The markets app (`morpho-apps/apps/markets-app`) already implements the Midnight flows, but under its UI-specific `ActionFlow` abstraction: + +- market/taker flows produce optional approval transactions, optional `Midnight.setIsAuthorized(...)`, and one final bundler transaction; +- maker/limit flows produce optional approval transactions, optional ratifier authorization, either one EOA root signature or one contract-wallet ratify-root transaction, then one mempool submit transaction; +- some user-level flows are multi-transaction (`supplyCollateral` before posting a borrow offer); +- repay / withdraw collateral already goes through `MidnightBundles.midnightBundlesV1RepayAndWithdrawCollateral(...)`, so the app sees one final bundled tx plus optional pre-execution approval / authorization items; +- none of the current markets app builders use `ActionFlow` `before` / `after` callbacks. + +This TIB freezes the minimal SDK output-shape change needed before migrating those Midnight action builders into `morpho-sdk`. + +That minimal change still touches shared `morpho-sdk` action-flow types and interfaces. `Requirement` can no longer mean only "signature requirement", transaction requirements can no longer mean only optional approval / authorization prerequisites, and `buildTx(...)` must accept the collected signature list the markets app already passes through its `ActionFlow` engine. Existing Blue / MarketV1 / vault methods may keep their narrower concrete return types, but the shared interfaces need to become compatible with the markets app's current execution model so the Midnight implementation does not force a bespoke integrator migration. + +The compatibility constraint is intentionally two-sided. For existing `morpho-sdk` consumers, implementing Midnight action flows should not turn the shared action interface into a broad breaking migration: existing flows should keep the same `{ getRequirements, buildTx }` execution model, and any shared type widening should be source-compatible wherever the current method can stay narrower. For the markets app, those same shared types must become wide enough to represent its existing signature-first `ActionFlow` model, ordered call requests, and mandatory prelude transactions. This keeps Midnight reusable for future integrators without making current SDK consumers absorb large unrelated changes, while keeping the markets app migration diff mostly limited to replacing app-owned protocol builders with SDK calls plus one adapter. + +## Goals / Non-Goals + +**Goals** + +- Implement Midnight action flows in `morpho-sdk` from the markets app's working protocol implementation, so future integrators can reuse the same paths instead of rebuilding them app-side. +- Minimize the markets app migration by preserving its `ActionFlow` execution model, centralizing the adapter, and moving only protocol construction into the SDK. +- Keep the public SDK contract centered on `{ getRequirements, buildTx }`. +- Represent every currently implemented markets app flow without adding an SDK `ActionFlow` engine. +- Preserve the existing `Transaction` shape: `{ to, value, data, action }`. +- Preserve action-layer purity: actions stay synchronous, encode-only, and deep-frozen. +- Keep existing Blue / MarketV1 / vault methods source-compatible; widen shared action-flow types / interfaces only where needed for the markets app's minimum-change migration. +- Avoid large breaking changes for existing `morpho-sdk` consumers while maximizing compatibility with the markets app's current action-flow shape. +- Make requirement ordering explicit enough for multi-step Midnight flows. +- Keep Midnight bundle token pulls approval-only in the first implementation, matching the current markets app builders. +- Add SDK support for constructing fixed-rate offer chains, because the markets app currently needs this protocol utility to build one maker order from several time-bounded offers. +- Support both maker consent paths: EOA / EIP-7702 signature and contract-wallet ratify-root. + +**Non-Goals** + +- No `ActionFlowProvider`, `CallRequest`, `before`, or `after` clone in `morpho-sdk`. +- No generic DAG / dependency graph of steps. +- No `buildTxs()` as the primary interface. +- No validation requirement objects. SDK-owned validation throws typed errors from entity / requirement resolution; app-owned preflights such as quote previews and tick-spacing assertions may continue to throw the app's user-facing errors before the SDK call. +- No ERC2612 or Permit2 token-pull support for Midnight bundle calls in the first PR. This is a follow-up once a product flow needs it. +- No exposed `reduceOnly` input, unit-target take entry points, referral fee input, max-continuous-fee input, or take-lend collateral-withdrawal input in the first PR. The implementation hardcodes the current markets app defaults where the bundle ABI requires those fields. +- No SDK modeling for app-only forms, dialogs, or UI copy. + +**Deferred follow-up PR** + +The first implementation should be limited to surface area the current markets app can actually consume. A follow-up PR, stacked on the implementation PR, can add broader protocol coverage: + +- ERC2612 and Permit2 SignatureTransfer support for Midnight bundle token pulls; +- unit-target take helpers in addition to the app's asset-targeted take flows with unit slippage guards; +- exposed `reduceOnly` for take flows; +- secondary bundle knobs such as referral fees, max continuous fee caps, and take-lend collateral withdrawals. + +## Decision + +Implement Midnight as regular `morpho-sdk` entity / action flows that return the same lazy output shape as existing SDK flows. Do not introduce a second SDK flow engine. Instead, widen the existing action output / requirement interfaces just enough for the markets app's current `ActionFlow` engine to adapt the SDK result with one shared adapter. + +Concretely, keep `buildTx` as the final transaction builder and widen `getRequirements` into an **ordered list of pre-execution items**. + +```ts +export interface ActionOutput< + TAction extends BaseAction = TransactionAction, + TSignatures = RequirementSignature, +> { + readonly getRequirements: () => Promise; + readonly buildTx: (signatures?: TSignatures) => Readonly>; +} + +export type MidnightActionSignatures = + | MidnightOfferRootSignature + | readonly MidnightOfferRootSignature[]; +``` + +Semantics: + +1. `getRequirements()` returns every item that must be satisfied **before** `buildTx()`'s transaction is sent. +2. Items are already filtered: if an approval / authorization is not needed, it is omitted. +3. Returned transaction items are ordered and must be executed in relative order. +4. Maker signature items in the initial Midnight implementation may be collected before transaction items because the signed offer-tree typed data is fully determined during entity resolution and does not depend on a prerequisite transaction being mined. +5. A transaction item is not necessarily an approval; it can be an authorization, contract-wallet ratify-root, or mandatory prelude transaction. +6. A signature item returns a `RequirementSignature` value. Existing one-signature flows may pass that value directly into `buildTx(signature)`; Midnight maker flows use `MidnightActionSignatures` and may pass the collected `readonly MidnightOfferRootSignature[]` into `buildTx(signatures)`. +7. Existing methods may keep narrower return types; new Midnight methods use `ActionRequirement`. + +This is the smallest compatible change: Midnight flows that are one final tx remain one final tx, flows with required prelude txs place those prelude txs in `getRequirements()`, and the markets app can forward the maker signature it already collects instead of learning a keyed SDK-owned flow engine. + +## Description: markets app migration boundary + +The markets app can keep its UI-specific `ActionFlow` execution engine. Because the SDK implementation is based on the app's current protocol builders, the migration target is a thin adapter from the proposed SDK `ActionOutput` into the app's existing `signatureRequests` / `callRequests` shape, not a port of `ActionFlow` into the SDK. + +The concrete SDK implementation in the stacked implementation PR moves protocol execution into `morpho-sdk`, while leaving rate-form and display decisions in the markets app: + +- **SDK-owned protocol logic**: allowance reads, `Midnight.isAuthorized(...)` reads, ratifier selection, `Group` / `Tree` / `Payload` construction, fixed-rate offer-chain construction, root-signature payload generation, ratify-root calldata, Midnight API mempool validation, and `MidnightBundles` / `Midnight` calldata. +- **Integrator-owned app logic**: `ActionFlow` construction, step labels (`"Confirm"`, `"Approve loan token"`, `"Submit offer"`), form-specific copy, review-only display values (`offerExpiry`, date labels, token role labels), `onSuccess` routing, query invalidation, analytics, EIP-5792 batching behavior, `before` / `after` waits if the app ever adds them, user-facing error presentation, and final take constraints (`minUnits`, `maxUnits`) from the selected quote. +- **Retained preflight validation in the markets app**: existing quote/rate preview checks and tick-spacing assertions may stay app-side because they are used to produce immediate UX errors and review data. The SDK still performs the protocol checks it needs to build safe transactions and payloads. + +The SDK may expose neutral typed metadata so an integrator can label steps, but it must not expose labels or UI state. For example, `MidnightAuthorizationAction.args.authorized` is SDK metadata; `"Authorize bundler"` is app copy. + +### Protocol intent from Midnight source + +The migration should keep the markets app on the bundle paths it already uses: + +- `MidnightBundles.midnightBundlesV1BuyWithAssetsTargetAndWithdrawCollateral(...)` for take-lend taker flows; +- `MidnightBundles.midnightBundlesV1SupplyCollateralAndSellWithAssetsTarget(...)` for take-borrow taker flows with `loanAssets > 0`; +- direct `Midnight.supplyCollateral(...)` only for supply-only branches where there are no takeableOffers and the bundler would index `takeableOffers[0]`; +- `MidnightBundles.midnightBundlesV1RepayAndWithdrawCollateral(...)` for repay-only, withdraw-only, and repay+withdraw position flows. + +These bundle signatures are checked against `morpho-org/bundles` `main` commit `4c71ac5ee7254b2a448b6054e003bd81e171d86e` (`src/midnight/IMidnightBundlesV1.sol` and `src/midnight/MidnightBundlesV1.sol`). The local `midnight-sdk` ABI in this stack may lag that deploy while the implementation PR updates generated ABI inputs. + +This is not just a UI preference. `MidnightBundles` pulls tokens once from direct ERC-20 allowance in the first implementation, skips reverted stale offers while continuing through the provided take list, enforces exact asset targets with unit slippage guards, and performs the authorized `Midnight` calls on behalf of the taker. The app already shaped its flows around those semantics, so the SDK migration should preserve them to minimize app changes. + +Maker flows remain mempool flows, not bundle flows: + +- the SDK normalizes the provided offer set into content-addressed groups and a Merkle tree; +- the maker authorizes the chosen ratifier on `Midnight`; +- EOA / EIP-7702 makers sign the tree root for `EcrecoverRatifier`; +- contract-wallet makers send `SetterRatifier.setIsRootRatified(maker, root, true)`; +- the final transaction submits the encoded `Payload` to the mempool contract. + +### App-side adapter + +The markets app can adapt SDK output once and reuse the adapter across every screen. The adapter description here is intentionally illustrative, not an implementation-ready patch. The implementation PR can choose different function names, labels, and control flow as long as the boundary stays the same: + +- SDK action outputs expose requirements and one final transaction builder; +- the app wallet layer turns SDK signature requirements into signature prompts; +- the app turns SDK transaction requirements into ordered call requests; +- collected maker signatures are passed back to the final `buildTx` call; +- labels, token roles, display copy, and success behavior stay in the app. + +The adapter preserves the current markets app UX where maker signature prompts are collected before transactions are sent. EOA maker offer-tree signatures do not depend on prior Midnight authorization or collateral-supply transactions, so grouping signatures first is protocol-compatible for the flows covered by this TIB. If a future Midnight flow introduces a signature that depends on a mined prerequisite transaction, the app adapter should gain an explicit dependency concept then. + +The label mapper stays in the markets app. It can map requirement types such as offer-tree signatures, ERC20 approvals, Midnight authorizations, ratify-root transactions, and collateral-supply transactions to screen-specific copy. This remains app-side because it depends on display concepts (`loan token`, `collateral token`, token symbols, and screen-specific final labels) that do not belong in `morpho-sdk`. + +If a markets app screen needs protocol metadata for follow-up behavior, the Midnight method can return a method-specific subtype that structurally extends `ActionOutput` with readonly metadata. The concrete maker flows return protocol fields such as `group`, `root`, and `ratifierType`; review-only display state such as `offerExpiry` stays in the markets app because the app owns display preparation while the SDK owns offer-set normalization, tree construction, and submit payload construction. That does not change the core `{ getRequirements, buildTx }` interface, and the app decides how to display the metadata. + +### Example 1: take-lend taker flow + +This migration sketch is illustrative. It describes ownership boundaries, not a required code patch. + +The app keeps quote selection, loading state, form guards, rate display math, and the rate-to-`minUnits` conversion. The app still passes the router quote it selected and owns the final label and success behavior. + +What leaves the app: + +- allowance read for the loan token; +- `buildApprovalCallRequestIfNeeded(...)` invocation for this flow; the SDK now resolves the loan-token pull as a direct approval transaction to `MidnightBundles`; +- `buildAuthorizeBundlerCallRequestIfNeeded(...)` invocation for this flow; +- take construction (`buildTakesFromOffers(...)` in the markets app) and `MidnightBundles` calldata encoding. + +What stays in the app: + +- quote selection and loading state that produced `offers`; +- rate and price display math, because the concrete SDK API receives the final unit constraint and does not own app display math; +- form guards if the app wants immediate local UX errors; +- labels (`"Take lend offers"`, `"Approve loan token"`, `"Authorize bundler"`); +- `ActionFlow` wrapping and `onSuccess`. + +The resulting SDK action should expose approval and Midnight authorization requirements when needed, then build the final `MidnightBundles` take-lend transaction. The exact helper names and call shapes are intentionally left to the implementation PR. + +The buy bundle has collateral-withdrawal, referral, max-continuous-fee, and `reduceOnly` slots that the current lend-market screen leaves at defaults. The first SDK implementation should hardcode the current app defaults instead of exposing those fields as public parameters. A follow-up PR can expose them when a product flow needs secondary-market exits or advanced bundle policy. + +Complexity for the markets app: **low**. This is mostly a mechanical builder replacement. The app keeps the existing rate-to-units lines, removes roughly the allowance / authorization / approval / take-encoding / final-call half of the builder, and updates tests to assert adapter inputs rather than raw app-built calldata. + +### Example 2: supply-collateral-make-borrow + +This migration sketch is illustrative. It describes the intended split of responsibilities, not an implementation-ready branch structure. + +This is the hardest current migration shape because it combines a mandatory collateral prelude transaction with maker consent. The app should keep form-level validation, rate / tick / expiry preparation, review display state, ratifier selection, tick-spacing preflight, labels, and the `ActionFlow` wrapper. The SDK should accept only a tree-like offer set for maker flows, then own collateral approval requirements, collateral supply transaction construction, offer-set normalization, tree validation, mempool validation, ratifier requirements, root signature / ratify-root requirements, payload construction, and the final submit transaction. + +This case intentionally stays approval-based for collateral and reserve transfers. The mandatory `MidnightSupplyCollateralAction` and maker reserve approvals target the core `Midnight` contract / mempool path, not a `MidnightBundles` function that accepts `TokenPermit`. Introducing token permits here would require a different protocol entry point rather than an app-only SDK migration. + +`MidnightApi.validateMempoolPayload(...)` keeps the API-helper behavior: it returns the raw validation result as `{ valid, issues }` so low-level callers can decide how to surface policy failures. `Tree.mempoolValidate(...)` / `TreeUtils.mempoolValidate(...)` are the SDK-owned safety boundary for action flows, so they must branch on `valid` and throw a typed `MidnightMempoolValidationError` carrying the returned `issues` before the entity exposes `midnightOfferRootSignature`, ratify-root requirements, or submit calldata. + +`getOffersData(...)` remains side-agnostic because it prepares any valid tree-like offer set. The low-level take transaction builders enforce take-side semantics: `takeLend` requires maker-sell takeable offers, and `takeBorrow` / `supplyCollateralTakeBorrow` require maker-buy takeable offers. The named maker entity flows enforce maker-side semantics: `makeLend` requires maker-buy offers, and `makeBorrow` / `supplyCollateralMakeBorrow` require maker-sell offers. + +Collateral-only handling must be explicit. Blue's combined supply-collateral-and-borrow flow rejects a zero borrow amount, and callers use direct `supplyCollateral` for collateral-only behavior. Midnight should follow the same principle unless the implementation PR deliberately chooses a different product contract. If the markets screen keeps a collateral-only branch, that branch should route directly to `supplyCollateral` or reject the combined maker flow before offer-tree preparation. It should not prepare an empty offer tree or call maker-offer helpers with no offers. + +What leaves the app: + +- collateral allowance read and collateral approval construction; +- collateral supply calldata construction; +- `buildMakeOfferRequests(...)`, `Tree`, `Payload`, and root payload state; +- ratifier authorization read / calldata; +- EOA root-signature payload mutation and Setter ratify-root calldata. + +What stays in the app: + +- form-level guards and user-facing copy (`"Rate is required"`, empty amount checks) unless the app chooses to rely entirely on SDK typed errors; +- market loading, final review display, ratifier selection, and tick-spacing preflight; +- `ActionFlow` execution through the shared adapter; +- final labels and requirement labels; +- no token-permit UI branch for this collateral prelude; the SDK returns an approval transaction because the core Midnight call used by this migration has no `TokenPermit` argument; +- success routing with the created group when a maker offer exists, plus local review display of `offerExpiry`. + +Complexity for the markets app: **medium**. The code removal is still large, but the remaining app code is the code it already owns: form validation, rate / tick / expiry preparation, tree-like offer-set input preparation, labels, and `offerExpiry` display. The SDK action should return enough metadata for the app to route `onSuccess` with the created group when a maker offer exists. No markets app flow requires `ActionFlow.before`, `ActionFlow.after`, a DAG, or `buildTxs()`. + +### Example 3: repay / withdraw through MidnightBundles + +The current app already minimizes transactions here by using `MidnightBundles.midnightBundlesV1RepayAndWithdrawCollateral(...)` for repay-only, withdraw-only, and combined flows. The SDK migration should keep that shape. + +This migration sketch is illustrative. It describes the desired shape, not a literal app patch. + +The app keeps input validation, final label selection, `ActionFlow` wrapping, and success behavior. The SDK should own the loan-token allowance read, approval requirement selection, bundler authorization requirement, single-collateral withdrawal struct construction, and final `MidnightBundles.midnightBundlesV1RepayAndWithdrawCollateral(...)` calldata encoding. + +The SDK requirements should include a loan-token pull requirement only when repay assets are positive, plus a MidnightBundles authorization requirement when needed. The final transaction remains one bundle transaction for repay-only, withdraw-only, and combined repay-withdraw flows. + +The current markets app parameter is named `repayUnits`, and its implementation relies on the current `referralFeePct === 0` identity where the bundle `assets` argument equals the units passed to `Midnight.repay`. The SDK API should still be assets-denominated because the bundle ABI is assets-denominated. If referral fees are ever exposed as non-zero inputs, the caller must convert explicitly instead of relying on the current units-to-assets identity; the latest bundle source documents full repayment of debt `D` as `assets = floor(D * WAD / (WAD - referralFeePct))`. + +What leaves the app: + +- loan-token allowance read; +- bundler authorization read; +- loan-token approval construction; the SDK now resolves the repay token pull as an approval transaction to `MidnightBundles`; +- `collateralWithdrawals` struct construction; +- `MidnightBundles.midnightBundlesV1RepayAndWithdrawCollateral(...)` calldata encoding. + +What stays in the app: + +- `validateInputs(...)` or equivalent form-level guards; +- the final label switch between `"Repay"`, `"Withdraw collateral"`, and `"Repay and withdraw collateral"`; +- `ActionFlow` wrapping and `onSuccess`. + +Complexity for the markets app: **low**. The important migration detail is that this does **not** become a two-step direct `repay` then `withdrawCollateral` flow. The SDK keeps the same final bundle transaction the app uses today, so app risk is mostly around label/test updates and adapter reuse. + +## Type changes + +### Requirement aliases + +Add explicit aliases for ordered call requirements and Midnight maker signatures, without changing the shape of existing `Requirement` objects. + +```ts +export type SignatureRequirementAction = + | PermitAction + | Permit2Action + | MidnightOfferRootSignatureAction; + +export type RequirementSignatureArgs = + | PermitArgs + | Permit2Args + | MidnightOfferRootSignatureArgs; + +export interface Requirement< + TAction extends SignatureRequirementAction = PermitAction | Permit2Action, + TArgs extends RequirementSignatureArgs = PermitArgs | Permit2Args, +> { + readonly sign: ( + client: WalletClient, + userAddress: Address, + ) => Promise>; + readonly action: TAction; +} + +export interface RequirementSignature< + TAction extends SignatureRequirementAction = PermitAction | Permit2Action, + TArgs extends RequirementSignatureArgs = PermitArgs | Permit2Args, +> { + readonly args: TArgs; + readonly action: TAction; +} + +export type MidnightOfferRootRequirement = Requirement< + MidnightOfferRootSignatureAction, + MidnightOfferRootSignatureArgs +>; + +export type MidnightOfferRootSignature = RequirementSignature< + MidnightOfferRootSignatureAction, + MidnightOfferRootSignatureArgs +>; + +export type BlueTokenSignatureRequirement = + | Requirement + | Requirement; + +export type TokenSignatureRequirement = BlueTokenSignatureRequirement; + +export type BlueTokenRequirementSignature = + | RequirementSignature + | RequirementSignature; + +export type TokenRequirementSignature = BlueTokenRequirementSignature; + +export type AnyRequirementSignature = + | TokenRequirementSignature + | MidnightOfferRootSignature; + +export type SignatureRequirement = + | TokenSignatureRequirement + | MidnightOfferRootRequirement; +``` + +Compatibility: + +- Existing `permit` and Blue `permit2` requirement objects stay structurally identical. +- Existing consumers that check `"sign" in requirement` still work. +- New consumers can discriminate on `requirement.action.type`. +- Midnight maker flows can return `MidnightOfferRootRequirement`; taker and repay flows in the first implementation return only call requirements. +- Midnight bundle token signatures are intentionally not represented in the first implementation. A follow-up PR should add a distinct requirement type for Permit2 SignatureTransfer rather than reusing Blue's `action.type === "permit2"`, because Blue signs `PermitSingle` and Midnight Bundles consume `permitTransferFrom`. + +### Midnight bundle permit metadata + +Keep the Midnight bundle permit shape in `morpho-sdk` because it is introduced by the SDK's `MidnightBundles` action encoders: + +```ts +export enum PermitKind { + None = 0, + ERC2612 = 1, + Permit2 = 2, +} + +export type MidnightTokenPermit = + | { + readonly kind: PermitKind.None; + readonly data: "0x"; + } + | { + readonly kind: PermitKind.ERC2612 | PermitKind.Permit2; + readonly data: Hex; + }; +``` + +This is action-encoding metadata, not UI state. The first implementation only encodes `PermitKind.None`; the non-`None` variants are ABI names reserved for the deferred token-signature PR. + +### Call requirements + +Add a named call-requirement union. Existing raw `Transaction<...>` requirement values stay valid. + +```ts +export type CallRequirementAction = + | ERC20ApprovalAction + | MorphoAuthorizationAction + | MidnightAuthorizationAction + | SetterRatifierRatifyRootAction + | MidnightSupplyCollateralAction; + +export type CallRequirement = Readonly>; + +export type ActionRequirement = CallRequirement | SignatureRequirement; +``` + +`MidnightSupplyCollateralAction` is included because it can be a mandatory prelude transaction for a currently implemented app flow: + +- `supplyCollateralMakeBorrow`: supply collateral first, then submit the maker borrow offer. + +Repay / withdraw collateral does **not** need a mandatory repay prelude in the app-compatible migration, because it remains one final `MidnightBundles.midnightBundlesV1RepayAndWithdrawCollateral(...)` transaction. + +### New Midnight requirement actions + +Use contract-specific action names for SDK metadata. The calldata targets `Midnight`, `MidnightBundles`, `EcrecoverRatifier`, `SetterRatifier`, and the mempool contract. + +```ts +export interface MidnightAuthorizationAction + extends BaseAction< + "midnightAuthorization", + { + authorized: Address; + isAuthorized: boolean; + onBehalf: Address; + } + > {} + +export interface SetterRatifierRatifyRootAction + extends BaseAction< + "setterRatifierRatifyRoot", + { + maker: Address; + root: Hex; + isRootRatified: boolean; + } + > {} + +export interface MidnightOfferRootSignatureAction + extends BaseAction< + "midnightOfferRootSignature", + { + root: Hex; + ratifier: Address; + offers: number; + } + > {} + +export interface MidnightOfferRootSignatureArgs { + readonly owner: Address; + readonly root: Hex; + readonly signature: Hex; + readonly payload: Hex; +} +``` + +`MidnightOfferRootSignatureArgs.payload` is the encoded mempool payload produced after the root signature is collected by the SDK requirement. `buildTx(signatures?)` selects the `midnightOfferRootSignature` result from the collected signature list, validates that its owner, root, ratifier, and offer count match the prepared flow, then uses its payload as the final submit calldata. + +### New final action metadata + +Add action union members only; do not change `Transaction`. + +```ts +export type MidnightOfferSetInput = + | Offer + | readonly Offer[] + | Group + | readonly Group[] + | Tree; + +export interface MidnightTakeLendAction + extends BaseAction< + "midnightTakeLend", + { + market: Hex; + assets: bigint; + minUnits: bigint; + taker: Address; + takeableOffers: number; + deadline: bigint; + } + > {} + +export interface MidnightTakeBorrowAction + extends BaseAction< + "midnightTakeBorrow", + { + market: Hex; + loanAssets: bigint; + maxUnits: bigint; + taker: Address; + receiver: Address; + collateralSupplies: number; + takeableOffers: number; + deadline: bigint; + } + > {} + +export interface MidnightSupplyCollateralAction + extends BaseAction< + "midnightSupplyCollateral", + { + market: Hex; + collateralIndex: bigint; + assets: bigint; + onBehalf: Address; + } + > {} + +export interface MempoolSubmitOffersAction + extends BaseAction< + "mempoolSubmitOffers", + { + groups: readonly Hex[]; + root: Hex; + maker: Address; + ratifier: Address; + ratifierType: "ecrecover" | "setter"; + offers: number; + } + > {} + +export interface MidnightRedeemAction + extends BaseAction< + "midnightRedeem", + { + market: Hex; + units: bigint; + onBehalf: Address; + receiver: Address; + } + > {} + +export interface MidnightRepayWithdrawCollateralAction + extends BaseAction< + "midnightRepayWithdrawCollateral", + { + market: Hex; + repayAssets: bigint; + collateralWithdrawals: number; + onBehalf: Address; + collateralReceiver: Address; + deadline: bigint; + } + > {} + +export interface MidnightCancelOfferAction + extends BaseAction< + "midnightCancelOffer", + { + group: Hex; + amount: bigint; + onBehalf: Address; + } + > {} +``` + +Maker entity methods accept a tree-like `MidnightOfferSetInput` only. The caller may pass a single offer, an array of offers, pre-grouped offers, or a tree; it does not pass derived `groups`, `root`, compression, signature payload, or mempool calldata. The entity normalizes the offer set, constructs the groups and tree, derives the root and signature input, validates the router / mempool payload, and passes only prepared encode inputs into the final action builder. `MempoolSubmitOffersAction` may expose derived metadata such as `groups`, `root`, and `offers` for adapters and `onSuccess` routing, but those fields are not caller inputs. + +Building a tree-like offer set remains caller-owned, but the Midnight SDK should expose the fixed-rate offer-chain utility the markets app currently owns. The utility returns `{ tick, startTimestamp, expiryTimestamp }` legs from a target APR, side, tick spacing, maturity, and requested window. The app still turns those legs into `Offer.create(...)` inputs and review display state. + +Extend `TransactionAction` with these action interfaces and the Midnight requirement action interfaces above (`MidnightAuthorizationAction`, `SetterRatifierRatifyRootAction`). + +## Minimal helper changes + +### Midnight approval requirement helper + +Do not reuse the top-level Blue / MarketV1 `getRequirements(...)` helper for Midnight, because it hardcodes `bundler3.generalAdapter1` as spender and its signature paths emit Bundler3 actions. The first Midnight action-flow implementation mirrors the current markets app and returns classic ERC-20 approval transactions only for token pulls. The spender is explicit because current flows need both `Midnight` (maker reserves and direct collateral supply) and `MidnightBundles` (taker and repay/withdraw bundle calls). + +```ts +async function getMidnightApprovalRequirements({ + viemClient, + chainId, + token, + owner, + spender, + amount, +}: { + readonly viemClient: Client; + readonly chainId: number; + readonly token: Address; + readonly owner: Address; + readonly spender: Address; + readonly amount: bigint; +}): Promise>[]> +``` + +The helper follows the current markets app policy: + +- if `amount === 0n`, return `[]`; +- read `allowance(owner, spender)`; +- return `[]` when the direct allowance already covers `amount`; +- otherwise return the approval transaction requirements for `token.approve(spender, amount)`. + +Approval transaction requirements can be a reset-then-approve pair for reset-requiring tokens. The reset requirement for amount `0n` must precede the positive approval requirement, which is one concrete reason returned transaction requirements are ordered rather than just a set. + +Bundle action encoders still pass a `TokenPermit` struct because the ABI requires it, but the first implementation always uses: + +```ts +{ kind: PermitKind.None, data: "0x" } +``` + +The deferred token-signature PR can add ERC2612 and Permit2 SignatureTransfer support. That PR should keep the bundle spender explicit, validate the signed spender and amount, and not reuse Blue's `permit2` requirement shape because Midnight's Permit2 branch signs SignatureTransfer, not PermitSingle. + +Midnight callers still supply the spender explicitly: + +- `MidnightBundles` for take-lend, take-borrow with `loanAssets > 0`, and repay / withdraw bundle flows; +- `Midnight` for direct `supplyCollateral` branches and maker-offer reserve approvals (make-lend loan token approvals and supply-collateral-make-borrow collateral approvals). These direct / mempool paths do not have a `TokenPermit` argument in this migration and remain approval-transaction based. + +Never route a bundle token pull through the core `Midnight` allowance. Bundle flows should use `MidnightBundles` as spender so they do not churn the core `Midnight` allowance that open maker offers use for reserved amounts. + +### Midnight authorization helper + +Add a helper that reads `Midnight.isAuthorized(owner, authorized)` and returns one tx only when missing. + +```ts +async function getMidnightAuthorizationRequirement({ + viemClient, + chainId, + owner, + authorized, +}: { + viemClient: Client; + chainId: number; + owner: Address; + authorized: Address; +}): Promise> | undefined> +``` + +Returned tx: + +```ts +Midnight.setIsAuthorized(authorized, true, owner) +``` + +### Ratifier requirements + +The implementation keeps maker consent in the `MorphoMidnight` entity instead of exporting one large helper. This keeps the public helper surface comparable to Blue and keeps tree / payload construction at the entity boundary: + +```ts +async getOffersData(offerSet: MidnightOfferSetInput): Promise; + +private async getRatifierRequirements({ + offersData, +}): Promise; + +private buildSubmitOffersTx({ + offersData, + signatures, +}): Readonly>; +``` + +EOA / EIP-7702 maker: + +- optional `MidnightAuthorizationAction` for `EcrecoverRatifier`; +- one private `makeOfferRootRequirement(...)` result with `action.type === "midnightOfferRootSignature"`; +- `Requirement.sign(...)` calls the same typed-data root-signing path as the markets app and returns `{ action, args: { root, signature, payload } }`; +- `buildTx(signatures?)` selects the `midnightOfferRootSignature` result, validates the owner / root / ratifier / offer-count metadata against the prepared flow, and uses `signature.args.payload` as mempool calldata. + +Contract-wallet maker: + +- optional `MidnightAuthorizationAction` for `SetterRatifier`; +- one `SetterRatifierRatifyRootAction` transaction requirement from `getSetterRatifierRatifyRootRequirement(...)`, calling `SetterRatifier.setIsRootRatified(maker, root, true)` only when missing; +- `buildTx()` uses precomputed `Payload.encode(SetterRatifierUtils.ratify({ tree }))` as mempool calldata. + +## Layering + +The migration must preserve the monorepo's `Client → Entity → Action` split. + +- **Entity layer** performs SDK-owned reads and off-chain checks: allowances, `isAuthorized`, ratifier selection, offer-set normalization, tree / payload construction, Midnight API mempool validation, credit / withdrawable reads, and group generation. App-owned preflights such as quote previews, rate math, and tick-spacing assertions may run before the entity call. +- **Action layer** is synchronous and encode-only: it receives already-computed amounts, prepared calldata payloads, roots, and addresses from the entity boundary, then returns deep-frozen `Transaction` values. +- **Helpers** are pure unless explicitly placed in the requirement-resolution boundary. + +Important boundary calls: + +- group ids are content-addressed, not random: the entity normalizes the caller-provided offer set with the Midnight SDK, then uses `Group.create(offers)` / `GroupUtils.hash` so `group`, roots, payloads, cancel references, and `onSuccess` metadata all agree with the shared Midnight helpers; +- offer and tree construction derives the market `chainId` and `midnight` address from the chain-scoped SDK configuration / market data, never from router SDK defaults such as `DEFAULT_CHAIN_ID` or `DEFAULT_MIDNIGHT`; these fields are part of the offer-id preimage and must follow the selected chain; +- signing is inside `Requirement.sign`, not action-level; +- router validation through `Tree.mempoolValidate(...)` throws before a signature prompt is exposed; lower-level `MidnightApi` helpers may still return `{ valid, issues }` for raw API consumers; +- no raw `Error`; every new failure mode gets a typed error in the package that owns the failing boundary. + +Bundle action builders encode ABI policy knobs with the current markets app defaults: + +- `reduceOnly` is encoded as `false`; +- `deadline` is still a required caller input, so the app can keep its current explicit max-deadline behavior or pass a bounded deadline later; +- `maxContinuousFee` is encoded as `maxUint256` for buy / sell bundle paths that expose it; +- referral parameters are encoded as `0n` and `zeroAddress`; +- take-lend collateral withdrawals are encoded as an empty list and a zero collateral receiver. + +These fields should not be first-iteration public inputs. Exposing them before the markets app needs them creates untested action-flow surface. + +## Flow mapping + +### Take lend + +`getRequirements()` returns: + +1. optional loan-token approval requirement for `MidnightBundles`; +2. optional `MidnightAuthorizationAction` for `Midnight.setIsAuthorized(MidnightBundles, true, taker)`. + +`buildTx()` returns `MidnightTakeLendAction`: + +```ts +MidnightBundles.midnightBundlesV1BuyWithAssetsTargetAndWithdrawCollateral( + assets, + minUnits, + taker, + false, // reduceOnly + { kind: PermitKind.None, data: "0x" }, + takeableOffers, + [], + zeroAddress, + 0n, + zeroAddress, + maxUint256, + deadline, +) +``` + +No offer-root or token signature is involved in the first implementation. + +### Take borrow with `loanAssets > 0` + +`getRequirements()` returns: + +1. optional collateral-token approval requirement for `MidnightBundles` when new collateral is supplied; +2. optional `MidnightAuthorizationAction` for `Midnight.setIsAuthorized(MidnightBundles, true, taker)`. + +`buildTx()` returns `MidnightTakeBorrowAction`: + +```ts +MidnightBundles.midnightBundlesV1SupplyCollateralAndSellWithAssetsTarget( + loanAssets, + maxUnits, + taker, + false, // reduceOnly + taker, + collateralSuppliesWithNoPermit, + takeableOffers, + 0n, + zeroAddress, + maxUint256, + deadline, +) +``` + +No offer-root or token signature is involved in the first implementation. + +### Take borrow supply-only branch + +`getRequirements()` returns optional collateral approval to `Midnight`. + +`buildTx()` returns `MidnightSupplyCollateralAction`: + +```ts +Midnight.supplyCollateral(market, 0n, collateralAssets, onBehalf) +``` + +This branch remains approval-based because direct `Midnight.supplyCollateral(...)` has no `TokenPermit` argument. + +### Make lend + +`getRequirements()` returns: + +EOA / EIP-7702: + +1. optional loan-token approval to `Midnight` for `reservedLoanAssets + loanAssets`; +2. optional `MidnightAuthorizationAction` for the chosen ratifier; +3. one `midnightOfferRootSignature` requirement. + +Contract wallet: + +1. optional loan-token approval to `Midnight` for `reservedLoanAssets + loanAssets`; +2. optional `MidnightAuthorizationAction` for the chosen ratifier; +3. one `setterRatifierRatifyRoot` transaction requirement. + +`buildTx(signatures?)` returns `MempoolSubmitOffersAction` to the mempool contract. + +The make-lend method accepts a tree-like offer set. It must accept multi-market offer legs in the same tree when the markets share one loan token, matching the markets app's multi-limit-order / OCA basket flow. For those baskets, offers in the same group share one `consumed[maker][group]` counter on Midnight, so the new group contributes one reserve amount: the maximum leg reserve, with equal leg reserves expected for the current OCA shape. It is not the sum of every offer leg. The SDK throws a typed error when the tree would exceed the Midnight tree-size limit. + +The Midnight SDK also exposes a fixed-rate offer-chain utility consumed before this entity call. Given a target APR, side, tick spacing, maturity, and `[start, end]` window, it returns time-bounded legs that the app maps to `Offer.create(...)`. This replaces the app-owned protocol math without making the entity layer own form state or display labels. + +Maker reserve approvals stay transaction approvals in this migration. The final mempool submit payload does not consume ERC2612 or Permit2 token signatures, so supporting permits here would require a separate protocol entry point rather than a markets-app-only SDK migration. + +`reservedLoanAssets` and `reservedCollateralAssets` are cross-group protocol reserve amounts, not UI display values. All resting groups for the maker share the same direct `Midnight` allowance, so the approval target is the existing reserved amount across open groups plus the new group's reserve amount. The entity must derive existing reserved amounts from maker reserve state, including each open group's current consumed amount when that data is available from the API, or accept them through an explicit data object when the caller already fetched that state. They are added to the new group reserve amount before approval so a replacement approval does not under-cover already-open offers. + +### Borrow limit collateral-only branch + +`getRequirements()` returns optional collateral approval to `Midnight`. + +`buildTx()` returns `MidnightSupplyCollateralAction`. + +This branch remains approval-based because direct `Midnight.supplyCollateral(...)` has no `TokenPermit` argument. + +### Make borrow loan-only branch + +`getRequirements()` returns: + +EOA / EIP-7702: + +1. optional `MidnightAuthorizationAction` for the chosen ratifier; +2. one `midnightOfferRootSignature` requirement. + +Contract wallet: + +1. optional `MidnightAuthorizationAction` for the chosen ratifier; +2. one `setterRatifierRatifyRoot` transaction requirement. + +`buildTx(signatures?)` returns `MempoolSubmitOffersAction` to the mempool contract. + +### Borrow limit collateral + loan branch + +`getRequirements()` returns: + +EOA / EIP-7702: + +1. optional collateral approval to `Midnight`; +2. **mandatory** `MidnightSupplyCollateralAction` transaction requirement; +3. optional `MidnightAuthorizationAction` for the chosen ratifier; +4. one `midnightOfferRootSignature` requirement. + +Contract wallet: + +1. optional collateral approval to `Midnight`; +2. **mandatory** `MidnightSupplyCollateralAction` transaction requirement; +3. optional `MidnightAuthorizationAction` for the chosen ratifier; +4. one `setterRatifierRatifyRoot` transaction requirement. + +`buildTx(signatures?)` returns only the final `MempoolSubmitOffersAction`. + +This branch is the reason `getRequirements()` must be allowed to return mandatory prelude transactions, not only optional prerequisites. + +### Redeem at maturity + +Pre-read / validation happens before returning the action output: + +- `updatePositionView(...)` gives accrued `creditUnits` and remaining `pendingFeeUnits`; +- compute `redeemUnits = creditUnits - pendingFeeUnits`, using the `midnight-sdk` `positionData.faceValue` getter when the SDK consumer provides an `AccrualPosition`; +- resolve `requestedUnits = params.units ?? redeemUnits`; +- `requestedUnits > 0`; +- `requestedUnits <= creditUnits`; +- `withdrawable(marketId) >= requestedUnits`. + +Do not default this flow to raw, unaccrued `positionData.credit`. `Midnight.withdraw(...)` calls `_updatePosition(...)` before burning credit, so bad-debt loss and accrued continuous fees can reduce the position's credit before the withdraw amount is applied. The default SDK flow should therefore use the accrued net face value. Integrators that intentionally want a different partial withdraw amount can still pass explicit `units`. + +The latest `morpho-org/midnight` implementation does not cap withdrawals at net face value. After `_updatePosition(...)`, `Midnight.withdraw(...)` decreases `pendingFee` pro rata and burns `units` from the updated `credit`; the protocol-compatible cap for explicit `units` is therefore accrued `creditUnits`, plus market `withdrawable` liquidity. This intentionally keeps the SDK default at net face value while still allowing integrators to request another protocol-valid partial amount explicitly. + +`getRequirements()` returns `[]`. + +`buildTx()` returns `MidnightRedeemAction`: + +```ts +Midnight.withdraw(market, redeemUnits, onBehalf, receiver) +``` + +### Repay / withdraw collateral + +All three app branches keep the current bundled execution path. + +Repay only: + +- `getRequirements()` returns optional loan-token approval requirement for `MidnightBundles`, then optional `MidnightAuthorizationAction` for `MidnightBundles`; +- `buildTx()` returns `MidnightRepayWithdrawCollateralAction`. + +Withdraw-only: + +- `getRequirements()` returns optional `MidnightAuthorizationAction` for `MidnightBundles`; +- `buildTx()` returns `MidnightRepayWithdrawCollateralAction` with `repayAssets === 0n`. + +Repay + withdraw: + +- `getRequirements()` returns optional loan-token approval requirement for `MidnightBundles`, then optional `MidnightAuthorizationAction` for `MidnightBundles`; +- `buildTx()` returns `MidnightRepayWithdrawCollateralAction`. + +```ts +MidnightBundles.midnightBundlesV1RepayAndWithdrawCollateral( + market, + repayAssets, + onBehalf, + { kind: PermitKind.None, data: "0x" }, + collateralWithdrawals, + receiver, + 0n, + zeroAddress, + deadline, +) +``` + +### Cancel offer + +`getRequirements()` returns `[]`. + +`buildTx()` returns `MidnightCancelOfferAction`: + +```ts +Midnight.setConsumed(group, maxUint256, onBehalf) +``` + +## Compatibility checklist + +This proposal is compatible with the current markets app flows, with the documented redeem default divergence, because: + +- every app `CallRequest` maps either to a `CallRequirement` or to `buildTx()`; +- every app maker `SignatureRequest` maps to `Requirement.sign(...)`; +- the `Transaction` wire shape is unchanged; +- no app builder currently needs `before` / `after` callback semantics; +- bundle token pulls keep the approval-transaction behavior the markets app uses today; +- direct core Midnight paths still return approval transactions because they do not consume `TokenPermit`; +- EOA maker flow still needs exactly one offer-root signature, selected from the collected signature list; +- contract-wallet maker flow needs zero signatures and one ratify-root tx; +- EOA maker signatures can be surfaced before transaction requirements, preserving the markets app's current signature-before-calls UX; +- repay / withdraw keeps the app's existing single final `MidnightBundles.midnightBundlesV1RepayAndWithdrawCollateral(...)` transaction; +- all multi-tx app flows can be represented by ordered requirements plus final `buildTx()`. +- redeem defaults to net face value, while explicit `units` can still reproduce the current app's post-update credit behavior as long as the amount does not exceed accrued credit or market withdrawable liquidity. + +The only semantic expansion is documented: returned transaction requirements are **ordered pre-execution items** and can include mandatory prelude transactions. Consumers must execute every returned item in order unless they intentionally replace it with an equivalent already-satisfied state. + +## Considered alternatives + +### Alternative 1: Add `buildTxs()` + +Return the whole transaction sequence from the action output. + +**Why rejected:** larger public API change, duplicates `getRequirements`, and forces every existing SDK consumer to learn a second execution model. The current app flows only need one final tx plus ordered pre-execution items. + +### Alternative 2: Port the app `ActionFlow` abstraction + +Copy `signatureRequests`, `callRequests`, `before`, and `after` into the SDK. + +**Why rejected:** this imports UI execution-engine concepts into a pure SDK package. The current markets app builders define no `before` / `after` requirements, so the extra machinery buys nothing for the initial migration. + +### Alternative 3: Keep `getRequirements()` limited to approvals / authorizations + +Expose only optional prerequisites and force callers to build prelude txs manually. + +**Why rejected:** supply-collateral-make-borrow cannot be expressed safely if the caller owns the prelude because the collateral supply must execute before the mempool submit transaction. Integrators would need bespoke sequencing outside the SDK, which defeats the migration goal. + +### Alternative 4: Include Midnight Permit / Permit2 immediately + +Add ERC2612 and Permit2 SignatureTransfer support to the first action-flow implementation because the bundle ABI already has `TokenPermit` slots. + +**Why rejected for the first PR:** the current markets app builders use direct ERC-20 approvals and `PermitKind.None`. Adding token signatures would expand the first SDK surface beyond what will be migrated and tested by the app. The follow-up PR can add this once there is a product path or dedicated test plan for it. + +## Implementation phases + +- **Phase 1 — Shared action-flow types / interfaces.** Add `ActionRequirement`, `CallRequirement`, widened `Requirement` / `RequirementSignature` unions, Midnight action interfaces, and type guards. This is the compatibility layer that lets the markets app keep its existing `ActionFlow` signature / call collection model while consuming SDK-built Midnight flows. Existing Blue / MarketV1 / vault methods keep their narrow return types. +- **Phase 2 — Requirement helpers.** Export / reuse `getRequirementsApproval` with explicit spender; add Midnight approval, authorization, and ratifier helpers. +- **Phase 3 — Pure action encoders.** Add `src/actions/midnight/*` encoders for final txs and prelude txs. Every encoder returns a deep-frozen `Transaction` and has colocated unit tests. +- **Phase 4 — Entity methods.** Add `MorphoMidnight` methods that perform RPC/off-chain reads, router validation, amount math, group generation, and return `{ getRequirements, buildTx }`. +- **Phase 5 — Integration tests.** Fork-test each flow shape: no requirement, approval reset, missing authorization, EOA root signature, contract-wallet ratify-root, mandatory prelude txs, and cancel offer. +- **Phase 6 — Docs / changeset.** Update package `AGENTS.md`, generated docs/JSDoc, README snippets, and add a minor changeset when code lands. + +## Security + +- **Wallet-decodable offer-tree signing.** The SDK must build the offer tree locally from the SDK input and validate the router response before exposing `midnightOfferRootSignature`. The wallet signs EIP-712 `OfferTree` typed data whose leaves are visible to the user, and the SDK verifies that the signed tree hashes to the root used in ratifier data. +- **No signing inside actions.** `Requirement.sign(...)` is the only signing boundary and takes a `WalletClient` from the integrator. +- **No hidden prelude txs.** Mandatory prelude transactions are visible in `getRequirements()` as typed `Transaction` values. +- **Authorization target is explicit.** `MidnightAuthorizationAction.args.authorized` is either `MidnightBundles`, `EcrecoverRatifier`, or `SetterRatifier`; never inferred by a consumer. +- **Approval target is explicit.** Midnight approval helper callers pass `spender`; no default to `GeneralAdapter1`. +- **Bundle token-pull policy is explicit.** Bundle flows use `MidnightBundles` as spender and never consume or reset the core `Midnight` allowance reserved by maker offers. +- **Deadline is explicit.** Passing `maxUint256` preserves current markets app behavior, but requiring `deadline` keeps unbounded validity intentional at each call site. +- **Typed errors only.** SDK-owned router / mempool validation, invalid protocol inputs, no credit, and insufficient withdrawable liquidity each get exported typed errors before implementation lands. App-owned preflights may keep app-specific user-facing errors. + +## Future considerations + +- If a future Midnight flow needs a real wait condition (`before` / `after` equivalent), add a small `wait` requirement kind at that time. Do not preemptively port app `ActionFlow`. +- If consumers strongly reject mandatory prelude transactions inside `getRequirements()`, revisit `buildTxs()` with evidence from integration feedback. + +## References + +- `packages/morpho-sdk/src/types/action.ts` — current `Transaction`, `Requirement`, and action unions. +- `packages/morpho-sdk/src/actions/requirements/getRequirements.ts` — current GeneralAdapter1-oriented requirement helper. +- `packages/morpho-sdk/src/actions/requirements/getRequirementsApproval.ts` — lower-level approval helper to reuse with explicit spender. +- `packages/midnight-sdk/src/signatures/{Group,Tree,Payload,EcrecoverRatifierUtils,SetterRatifierUtils}.ts` — existing framework-free Midnight group, tree, payload, and ratifier utilities that the hypothetical `morpho-sdk` flows should reuse or mirror. +- `morpho-org/morpho-apps/apps/markets-app/lib/modules/order/actions/lend-market/buildLendMarketOrderActionFlow.ts` — lend-market app flow. +- `morpho-org/morpho-apps/apps/markets-app/lib/modules/order/actions/borrow-market/buildBorrowMarketOrderActionFlow.ts` — borrow-market app flow. +- `morpho-org/morpho-apps/apps/markets-app/lib/modules/order/actions/lend-limit/buildLendLimitOrderActionFlow.ts` and `lib/modules/offer/buildMakeOffersActionFlow.ts` — lend-limit / OCA app flow. +- `morpho-org/morpho-apps/apps/markets-app/lib/modules/order/actions/borrow-limit/buildBorrowLimitOrderActionFlow.ts` — borrow-limit app flow. +- `morpho-org/morpho-apps/apps/markets-app/lib/modules/multi-limit-order/buildMultiLimitOrderActionFlow.ts` — multi-market OCA basket flow. +- `morpho-org/morpho-apps/apps/markets-app/lib/modules/order/actions/market-order.utils.ts` — `buildTakesFromOffers` take construction. +- `morpho-org/morpho-apps/apps/markets-app/lib/modules/order/actions/limit-order.utils.ts` — ratifier detection, root signing, and mempool submit. +- `morpho-org/morpho-apps/apps/markets-app/lib/modules/position/actions/redeem/buildRedeemActionFlow.ts` — redeem flow. +- `morpho-org/morpho-apps/apps/markets-app/lib/modules/position/actions/repay-withdraw/buildRepayWithdrawActionFlow.ts` — repay / withdraw collateral flow. +- `morpho-org/morpho-apps/apps/markets-app/lib/modules/offer/actions/buildCancelOfferActionFlow.ts` — cancel offer flow. +- `morpho-org/midnight/src/Midnight.sol` and `src/interfaces/IMidnight.sol` — core offer, authorization, position, consumed, repay, withdraw, and collateral semantics. +- `morpho-org/bundles/src/midnight/MidnightBundlesV1.sol` and `src/midnight/IMidnightBundlesV1.sol` — latest bundled taker and repay / withdraw entry points used by the markets app. +- `morpho-org/midnight/src/ratifiers/EcrecoverRatifier.sol` and `src/ratifiers/SetterRatifier.sol` — maker root-signature and ratify-root consent paths. +- Root [`AGENTS.md`](../../AGENTS.md) §1 (layering), §2 (forbidden patterns), §3 (types), §5 (testing), §6 (JSDoc), §7 (release). diff --git a/packages/midnight-sdk/src/abis.ts b/packages/midnight-sdk/src/abis.ts index 29555a00d..0b8a87e75 100644 --- a/packages/midnight-sdk/src/abis.ts +++ b/packages/midnight-sdk/src/abis.ts @@ -2717,9 +2717,9 @@ export const midnightAbi = [ /** * ABI JSON for the Midnight Bundles periphery used by app-compatible taker and repay flows. * - * Source: `morpho-org/morpho-apps` commit `4e903d545184a1f46b378c5c0c4ad414575a5b94`, - * `packages/contracts/solidity/interfaces/IMidnightBundles.sol`, adapted to the current - * Midnight `Market` and `Offer` struct fields exported by this package. + * Source: `morpho-org/bundles` commit `4c71ac5ee7254b2a448b6054e003bd81e171d86e`, + * `src/midnight/IMidnightBundlesV1.sol`, adapted to the current Midnight `Market` + * and `Offer` struct fields exported by this package. * * @example * ```ts @@ -2729,19 +2729,6 @@ export const midnightAbi = [ * ``` */ export const midnightBundlesAbi = [ - { - type: "function", - name: "PERMIT2", - inputs: [], - outputs: [ - { - name: "", - type: "address", - internalType: "address", - }, - ], - stateMutability: "view", - }, { type: "function", name: "MIDNIGHT", @@ -2757,7 +2744,7 @@ export const midnightBundlesAbi = [ }, { type: "function", - name: "buyWithUnitsTargetAndWithdrawCollateral", + name: "midnightBundlesV1BuyWithUnitsTargetAndWithdrawCollateral", inputs: [ { name: "targetUnits", @@ -2774,6 +2761,11 @@ export const midnightBundlesAbi = [ type: "address", internalType: "address", }, + { + name: "reduceOnly", + type: "bool", + internalType: "bool", + }, { name: "loanTokenPermit", type: "tuple", @@ -2807,13 +2799,23 @@ export const midnightBundlesAbi = [ type: "address", internalType: "address", }, + { + name: "maxContinuousFee", + type: "uint256", + internalType: "uint256", + }, + { + name: "deadline", + type: "uint256", + internalType: "uint256", + }, ], outputs: [], stateMutability: "nonpayable", }, { type: "function", - name: "supplyCollateralAndSellWithUnitsTarget", + name: "midnightBundlesV1SupplyCollateralAndSellWithUnitsTarget", inputs: [ { name: "targetUnits", @@ -2830,6 +2832,11 @@ export const midnightBundlesAbi = [ type: "address", internalType: "address", }, + { + name: "reduceOnly", + type: "bool", + internalType: "bool", + }, { name: "receiver", type: "address", @@ -2857,13 +2864,23 @@ export const midnightBundlesAbi = [ type: "address", internalType: "address", }, + { + name: "maxContinuousFee", + type: "uint256", + internalType: "uint256", + }, + { + name: "deadline", + type: "uint256", + internalType: "uint256", + }, ], outputs: [], stateMutability: "nonpayable", }, { type: "function", - name: "buyWithAssetsTargetAndWithdrawCollateral", + name: "midnightBundlesV1BuyWithAssetsTargetAndWithdrawCollateral", inputs: [ { name: "targetBuyerAssets", @@ -2880,6 +2897,11 @@ export const midnightBundlesAbi = [ type: "address", internalType: "address", }, + { + name: "reduceOnly", + type: "bool", + internalType: "bool", + }, { name: "loanTokenPermit", type: "tuple", @@ -2913,13 +2935,23 @@ export const midnightBundlesAbi = [ type: "address", internalType: "address", }, + { + name: "maxContinuousFee", + type: "uint256", + internalType: "uint256", + }, + { + name: "deadline", + type: "uint256", + internalType: "uint256", + }, ], outputs: [], stateMutability: "nonpayable", }, { type: "function", - name: "supplyCollateralAndSellWithAssetsTarget", + name: "midnightBundlesV1SupplyCollateralAndSellWithAssetsTarget", inputs: [ { name: "targetSellerAssets", @@ -2936,6 +2968,11 @@ export const midnightBundlesAbi = [ type: "address", internalType: "address", }, + { + name: "reduceOnly", + type: "bool", + internalType: "bool", + }, { name: "receiver", type: "address", @@ -2963,13 +3000,23 @@ export const midnightBundlesAbi = [ type: "address", internalType: "address", }, + { + name: "maxContinuousFee", + type: "uint256", + internalType: "uint256", + }, + { + name: "deadline", + type: "uint256", + internalType: "uint256", + }, ], outputs: [], stateMutability: "nonpayable", }, { type: "function", - name: "repayAndWithdrawCollateral", + name: "midnightBundlesV1RepayAndWithdrawCollateral", inputs: [ { name: "market", @@ -3014,13 +3061,23 @@ export const midnightBundlesAbi = [ type: "address", internalType: "address", }, + { + name: "deadline", + type: "uint256", + internalType: "uint256", + }, ], outputs: [], stateMutability: "nonpayable", }, { type: "error", - name: "ApproveReturnedFalse", + name: "ContinuousFeeAboveMax", + inputs: [], + }, + { + type: "error", + name: "DeadlinePassed", inputs: [], }, { @@ -3043,6 +3100,11 @@ export const midnightBundlesAbi = [ name: "PctExceeded", inputs: [], }, + { + type: "error", + name: "NotReduceOnly", + inputs: [], + }, { type: "error", name: "SellerAssetsTooLow", diff --git a/packages/midnight-sdk/src/offers/OfferChainUtils.test.ts b/packages/midnight-sdk/src/offers/OfferChainUtils.test.ts new file mode 100644 index 000000000..2703395a3 --- /dev/null +++ b/packages/midnight-sdk/src/offers/OfferChainUtils.test.ts @@ -0,0 +1,241 @@ +import { Time } from "@morpho-org/morpho-ts"; +import fc from "fast-check"; +import { formatUnits } from "viem"; +import { describe, expect, test } from "vitest"; +import { InvalidOfferParameterError } from "../errors.js"; +import { TickLib } from "../math/index.js"; +import { OfferChainUtils } from "./OfferChainUtils.js"; + +const YEAR = Time.s.from.y(1n); +const YEAR_NUMBER = Number(YEAR); +const DRIFT = 0.1; +const RATE_EPSILON = 0.00001; +const NOW = 1_767_225_600n; +const MATURITY = NOW + YEAR; +const MAX_EXPIRY = OfferChainUtils.getMaxFixedRateOfferChainEndTimestamp({ + maturityTimestamp: MATURITY, + chainStartTimestamp: NOW, +}); + +const defaultParams = { + side: "lend", + targetRate: 0.05, + tickSpacing: 4n, + maturityTimestamp: MATURITY, + chainStartTimestamp: NOW, + chainEndTimestamp: MAX_EXPIRY, +} as const; + +describe("OfferChainUtils.buildFixedRateOfferChain", () => { + test("default", () => { + const chain = OfferChainUtils.buildFixedRateOfferChain(defaultParams); + + expect(chain.length).toBeGreaterThan(0); + expect(chain).toStrictEqual( + OfferChainUtils.buildFixedRateOfferChain(defaultParams), + ); + for (const [index, leg] of chain.entries()) { + expect(leg.tick % defaultParams.tickSpacing).toBe(0n); + expect(leg.expiryTimestamp).toBeGreaterThan(leg.startTimestamp); + if (index > 0) { + expect(leg.startTimestamp).toBe(chain[index - 1]!.expiryTimestamp); + expect(leg.tick).toBeGreaterThan(chain[index - 1]!.tick); + } + } + }); + + test.each([ + "borrow", + "lend", + ] as const)("behavior: recovers target rate at every %s display edge", (side) => { + const chain = OfferChainUtils.buildFixedRateOfferChain({ + ...defaultParams, + side, + }); + + for (const leg of chain) { + const displayTimestamp = + side === "borrow" ? leg.expiryTimestamp : leg.startTimestamp; + const displayRate = rateAt({ + tick: leg.tick, + maturityTimestamp: defaultParams.maturityTimestamp, + timestamp: displayTimestamp, + }); + + expect(Math.abs(displayRate - defaultParams.targetRate)).toBeLessThan( + defaultParams.targetRate * 0.005 + 0.00001, + ); + } + }); + + test.each([ + "borrow", + "lend", + ] as const)("behavior: keeps %s rates on the maker-favorable side", (side) => { + const chain = OfferChainUtils.buildFixedRateOfferChain({ + ...defaultParams, + side, + }); + + for (const leg of chain) { + const startRate = rateAt({ + tick: leg.tick, + maturityTimestamp: defaultParams.maturityTimestamp, + timestamp: leg.startTimestamp, + }); + const expiryRate = rateAt({ + tick: leg.tick, + maturityTimestamp: defaultParams.maturityTimestamp, + timestamp: leg.expiryTimestamp, + }); + + if (side === "borrow") { + expect(startRate).toBeGreaterThanOrEqual( + defaultParams.targetRate * (1 - DRIFT) - RATE_EPSILON, + ); + expect(startRate).toBeLessThanOrEqual( + defaultParams.targetRate + RATE_EPSILON, + ); + expect(expiryRate).toBeLessThanOrEqual( + defaultParams.targetRate + RATE_EPSILON, + ); + } else { + expect(startRate).toBeGreaterThanOrEqual( + defaultParams.targetRate - RATE_EPSILON, + ); + expect(expiryRate).toBeGreaterThanOrEqual( + defaultParams.targetRate - RATE_EPSILON, + ); + expect(expiryRate).toBeLessThanOrEqual( + defaultParams.targetRate * (1 + DRIFT) + RATE_EPSILON, + ); + } + } + }); + + test("behavior: returns an empty chain when the grid cannot represent the rate", () => { + expect( + OfferChainUtils.buildFixedRateOfferChain({ + ...defaultParams, + targetRate: 0.0001, + side: "lend", + chainStartTimestamp: MATURITY - 2n * Time.s.from.d(1n), + chainEndTimestamp: MATURITY - Time.s.from.d(1n), + }), + ).toStrictEqual([]); + }); + + test("behavior: accepts tick spacing that does not divide max tick", () => { + const chain = OfferChainUtils.buildFixedRateOfferChain({ + ...defaultParams, + tickSpacing: 64n, + }); + + for (const leg of chain) { + expect(leg.tick % 64n).toBe(0n); + } + }); + + test("error: InvalidOfferParameterError", () => { + expect(() => + OfferChainUtils.buildFixedRateOfferChain({ + ...defaultParams, + targetRate: 0, + }), + ).toThrow(InvalidOfferParameterError); + expect(() => + OfferChainUtils.buildFixedRateOfferChain({ + ...defaultParams, + chainEndTimestamp: MAX_EXPIRY + 1n, + }), + ).toThrow(InvalidOfferParameterError); + }); + + test("behavior: property-based chain invariants", () => { + fc.assert( + fc.property( + fc.record({ + side: fc.constantFrom("borrow", "lend"), + targetRate: fc.double({ + min: 0.001, + max: 0.5, + noNaN: true, + noDefaultInfinity: true, + }), + tickSpacing: fc.integer({ min: 1, max: 96 }), + startOffset: fc.integer({ + min: 0, + max: Number(Time.s.from.d(30n)), + }), + ttm: fc.integer({ + min: Number(Time.s.from.d(30n)), + max: Number(YEAR), + }), + }), + (input) => { + const chainStartTimestamp = NOW + BigInt(input.startOffset); + const maturityTimestamp = chainStartTimestamp + BigInt(input.ttm); + const chainEndTimestamp = + OfferChainUtils.getMaxFixedRateOfferChainEndTimestamp({ + maturityTimestamp, + chainStartTimestamp, + }); + const chain = OfferChainUtils.buildFixedRateOfferChain({ + side: input.side, + targetRate: input.targetRate, + tickSpacing: BigInt(input.tickSpacing), + maturityTimestamp, + chainStartTimestamp, + chainEndTimestamp, + }); + + for (const [index, leg] of chain.entries()) { + expect(leg.tick % BigInt(input.tickSpacing)).toBe(0n); + if (input.side === "borrow") { + expect(leg.startTimestamp).toBeGreaterThanOrEqual( + chainStartTimestamp, + ); + } else { + expect(leg.expiryTimestamp).toBeGreaterThan(chainStartTimestamp); + } + expect(leg.expiryTimestamp).toBeLessThanOrEqual(chainEndTimestamp); + expect(leg.expiryTimestamp).toBeGreaterThan(leg.startTimestamp); + if (index > 0) { + expect(leg.startTimestamp).toBe( + chain[index - 1]!.expiryTimestamp, + ); + expect(leg.tick).toBeGreaterThan(chain[index - 1]!.tick); + } + } + }, + ), + { numRuns: 100 }, + ); + }); +}); + +describe("OfferChainUtils.getMaxFixedRateOfferChainEndTimestamp", () => { + test("default", () => { + expect(MAX_EXPIRY).toBe(NOW + ((MATURITY - NOW) * 75n) / 100n); + }); + + test("error: InvalidOfferParameterError", () => { + expect(() => + OfferChainUtils.getMaxFixedRateOfferChainEndTimestamp({ + maturityTimestamp: NOW, + chainStartTimestamp: NOW, + }), + ).toThrow(InvalidOfferParameterError); + }); +}); + +function rateAt(params: { + readonly tick: bigint; + readonly maturityTimestamp: bigint; + readonly timestamp: bigint; +}) { + const price = Number(formatUnits(TickLib.tickToPrice(params.tick), 18)); + const tau = Number(params.maturityTimestamp - params.timestamp) / YEAR_NUMBER; + + return (1 / price) ** (1 / tau) - 1; +} diff --git a/packages/midnight-sdk/src/offers/OfferChainUtils.ts b/packages/midnight-sdk/src/offers/OfferChainUtils.ts new file mode 100644 index 000000000..a4dcee8d7 --- /dev/null +++ b/packages/midnight-sdk/src/offers/OfferChainUtils.ts @@ -0,0 +1,460 @@ +import { type BigIntish, MathLib, Time } from "@morpho-org/morpho-ts"; +import { formatUnits, parseUnits } from "viem"; +import { MAX_TICK } from "../constants.js"; +import { InvalidOfferParameterError } from "../errors.js"; +import { TickLib } from "../math/index.js"; + +const FAVORABLE_RATE_DRIFT = 0.1; +const MAX_EXPIRY_TTM_NUMERATOR = 75n; +const MAX_EXPIRY_TTM_DENOMINATOR = 100n; +const MAX_CHAIN_LEGS = Number(MAX_TICK) + 1; +const SECONDS_PER_YEAR = Number(Time.s.from.y(1n)); + +/** One time-bounded offer leg in a fixed-rate Midnight offer chain. */ +export interface FixedRateOfferChainLeg { + /** Spacing-aligned Midnight tick for this offer. */ + readonly tick: bigint; + /** First timestamp at which the offer leg is active. */ + readonly startTimestamp: bigint; + /** Last timestamp at which the offer leg is active. */ + readonly expiryTimestamp: bigint; +} + +/** Parameters for {@link OfferChainUtils.buildFixedRateOfferChain}. */ +export interface BuildFixedRateOfferChainParams { + /** Maker side: `"lend"` for buy offers, `"borrow"` for sell offers. */ + readonly side: "borrow" | "lend"; + /** Target yearly fixed rate as a decimal number, for example `0.05` for 5%. */ + readonly targetRate: number; + /** Tick spacing enforced by the market. */ + readonly tickSpacing: BigIntish; + /** Market maturity timestamp in seconds. */ + readonly maturityTimestamp: BigIntish; + /** First timestamp the chain should cover. */ + readonly chainStartTimestamp: BigIntish; + /** Latest timestamp the chain may cover. */ + readonly chainEndTimestamp: BigIntish; +} + +interface NormalizedBuildFixedRateOfferChainParams { + readonly side: "borrow" | "lend"; + readonly targetRate: number; + readonly tickSpacing: bigint; + readonly maturityTimestamp: bigint; + readonly chainStartTimestamp: bigint; + readonly chainEndTimestamp: bigint; +} + +interface TauLeg { + readonly tick: bigint; + readonly tauMax: number; + readonly tauMin: number; +} + +/** + * Utilities for building time-bounded Midnight offer chains. + * + * @example + * ```ts + * import { OfferChainUtils } from "@morpho-org/midnight-sdk"; + * + * const legs = OfferChainUtils.buildFixedRateOfferChain({ + * side: "lend", + * targetRate: 0.05, + * tickSpacing: 4n, + * maturityTimestamp: 1_798_761_600n, + * chainStartTimestamp: 1_767_225_600n, + * chainEndTimestamp: 1_791_153_600n, + * }); + * console.log(legs[0]?.tick); + * ``` + */ +export namespace OfferChainUtils { + /** + * Maximum fraction of the initial time-to-maturity covered by a fixed-rate offer chain. + * + * @example + * ```ts + * import { OfferChainUtils } from "@morpho-org/midnight-sdk"; + * + * console.log(OfferChainUtils.MAX_EXPIRY_TTM_FRACTION); + * ``` + */ + export const MAX_EXPIRY_TTM_FRACTION = 0.75; + + /** + * Builds offer legs that approximate one fixed maker rate over time. + * + * A Midnight offer has one fixed price, so its displayed yearly rate changes + * as maturity approaches. The markets app uses this helper when a maker wants + * to post, for example, a 5% lend order for several months: the app builds + * several adjacent offers sharing the same reserve, each with a different + * tick and time window, so the order reviews and renders as a stable 5% order + * across the selected window instead of drifting upward as time passes. + * + * The returned legs are contiguous, use increasing spacing-aligned ticks, and + * stay on the maker-favorable side of `targetRate` within each leg: borrow + * chains stay at or below the target, while lend chains stay at or above it. + * Returns `[]` when the requested rate/window cannot be represented on the + * tick grid. + * + * @param params - Fixed-rate offer-chain parameters. + * @returns Offer legs that can be mapped to `Offer.create` inputs. + * @throws {InvalidOfferParameterError} when an input is invalid or the end timestamp exceeds the supported horizon. + * @example + * ```ts + * import { Offer, OfferChainUtils } from "@morpho-org/midnight-sdk"; + * + * const legs = OfferChainUtils.buildFixedRateOfferChain({ + * side: "lend", + * targetRate: 0.05, + * tickSpacing: market.tickSpacing, + * maturityTimestamp: market.params.maturity, + * chainStartTimestamp: now, + * chainEndTimestamp: expiry, + * }); + * + * const offers = legs.map((leg) => + * Offer.create({ + * market: market.params, + * buy: true, + * maker, + * tick: leg.tick, + * start: leg.startTimestamp, + * expiry: leg.expiryTimestamp, + * ratifier, + * maxAssets: loanAssets, + * }), + * ); + * ``` + */ + export function buildFixedRateOfferChain( + params: BuildFixedRateOfferChainParams, + ): readonly FixedRateOfferChainLeg[] { + const normalized = normalizeBuildParams(params); + const maxChainEndTimestamp = getMaxFixedRateOfferChainEndTimestamp({ + maturityTimestamp: normalized.maturityTimestamp, + chainStartTimestamp: normalized.chainStartTimestamp, + }); + if (normalized.chainEndTimestamp > maxChainEndTimestamp) { + throw new InvalidOfferParameterError({ + parameter: "chainEndTimestamp", + value: normalized.chainEndTimestamp, + instruction: `Use a timestamp no greater than "${maxChainEndTimestamp}".`, + }); + } + + const tauInitial = + Number(normalized.maturityTimestamp - normalized.chainStartTimestamp) / + SECONDS_PER_YEAR; + const tauStop = + Number(normalized.maturityTimestamp - normalized.chainEndTimestamp) / + SECONDS_PER_YEAR; + const legs = + normalized.side === "borrow" + ? buildBorrowChain({ + targetRate: normalized.targetRate, + tauInitial, + tauStop, + tickSpacing: normalized.tickSpacing, + }) + : buildLendChain({ + targetRate: normalized.targetRate, + tauInitial, + tauStop, + tickSpacing: normalized.tickSpacing, + }); + + return legs + .map((leg) => ({ + tick: leg.tick, + startTimestamp: tauToTimestamp( + normalized.maturityTimestamp, + leg.tauMax, + ), + expiryTimestamp: tauToTimestamp( + normalized.maturityTimestamp, + leg.tauMin, + ), + })) + .filter((leg) => leg.expiryTimestamp > leg.startTimestamp); + } + + /** + * Returns the latest supported end timestamp for a fixed-rate offer chain. + * + * Chains intentionally stop before the final part of the maturity window + * because rate sensitivity accelerates near maturity and would require too + * many short-lived offers for a stable maker quote. + * + * @param params - Maturity and chain-start timestamps. + * @returns Latest accepted chain end timestamp. + * @throws {InvalidOfferParameterError} when a timestamp is invalid. + * @example + * ```ts + * import { OfferChainUtils } from "@morpho-org/midnight-sdk"; + * + * const maxExpiry = OfferChainUtils.getMaxFixedRateOfferChainEndTimestamp({ + * maturityTimestamp: market.params.maturity, + * chainStartTimestamp: now, + * }); + * console.log(maxExpiry); + * ``` + */ + export function getMaxFixedRateOfferChainEndTimestamp(params: { + readonly maturityTimestamp: BigIntish; + readonly chainStartTimestamp: BigIntish; + }) { + const maturityTimestamp = normalizeSafeInteger( + "maturityTimestamp", + params.maturityTimestamp, + ); + const chainStartTimestamp = normalizeSafeInteger( + "chainStartTimestamp", + params.chainStartTimestamp, + ); + if (maturityTimestamp <= chainStartTimestamp) { + throw new InvalidOfferParameterError({ + parameter: "maturityTimestamp", + value: maturityTimestamp, + instruction: + "Use a maturity timestamp greater than chainStartTimestamp.", + }); + } + + return ( + chainStartTimestamp + + ((maturityTimestamp - chainStartTimestamp) * MAX_EXPIRY_TTM_NUMERATOR) / + MAX_EXPIRY_TTM_DENOMINATOR + ); + } +} + +function buildBorrowChain(params: { + readonly targetRate: number; + readonly tauInitial: number; + readonly tauStop: number; + readonly tickSpacing: bigint; +}): readonly TauLeg[] { + const lowerRate = params.targetRate * (1 - FAVORABLE_RATE_DRIFT); + if (lowerRate <= 0) return []; + + const legs: TauLeg[] = []; + let tauTop = params.tauInitial; + let iteration = 0; + + for (; iteration < MAX_CHAIN_LEGS; iteration++) { + const rawTick = + TickLib.priceToTick(rateToPriceAtTau(lowerRate, tauTop), 1n) - 1n; + if (rawTick < 0n || rawTick > MAX_TICK) break; + + const tick = floorTickToSpacing(rawTick, params.tickSpacing); + if (legs.at(-1)?.tick === tick) break; + + const tauMax = rateTau(tick, lowerRate); + const tauMin = rateTau(tick, params.targetRate); + if (tauMin >= tauTop) break; + if (tauMin < params.tauStop) break; + + legs.push({ tick, tauMax: Math.min(tauMax, tauTop), tauMin }); + tauTop = tauMin; + } + + if (iteration >= MAX_CHAIN_LEGS) { + throw new InvalidOfferParameterError({ + parameter: "tickSpacing", + value: params.tickSpacing, + instruction: `Borrow chain exceeded "${MAX_CHAIN_LEGS}" legs.`, + }); + } + + return legs; +} + +function buildLendChain(params: { + readonly targetRate: number; + readonly tauInitial: number; + readonly tauStop: number; + readonly tickSpacing: bigint; +}): readonly TauLeg[] { + const upperRate = params.targetRate * (1 + FAVORABLE_RATE_DRIFT); + const maxAlignedTick = floorTickToSpacing(MAX_TICK, params.tickSpacing); + const ceilingTick = minBigint( + highestDiscountTick(params.tickSpacing), + maxAlignedTick, + ); + + const tauCeiling = rateTau(ceilingTick, upperRate); + let tauBottom = Math.max(params.tauStop, tauCeiling); + if (tauBottom >= params.tauInitial) return []; + + const legs: TauLeg[] = []; + let iteration = 0; + + for (; iteration < MAX_CHAIN_LEGS; iteration++) { + const rawTick = TickLib.priceToTick( + rateToPriceAtTau(upperRate, tauBottom), + 1n, + ); + if (rawTick < 0n || rawTick > MAX_TICK || rawTick > maxAlignedTick) break; + + const tick = ceilTickToSpacing(rawTick, params.tickSpacing); + if (legs.at(-1)?.tick === tick) break; + + const tauMax = rateTau(tick, params.targetRate); + const tauMin = rateTau(tick, upperRate); + if (tauMax <= tauBottom) break; + + legs.push({ tick, tauMax, tauMin: Math.max(tauMin, tauBottom) }); + if (tauMax >= params.tauInitial) break; + + tauBottom = tauMax; + } + + if (iteration >= MAX_CHAIN_LEGS) { + throw new InvalidOfferParameterError({ + parameter: "tickSpacing", + value: params.tickSpacing, + instruction: `Lend chain exceeded "${MAX_CHAIN_LEGS}" legs.`, + }); + } + + const leftmostLeg = legs.at(-1); + if (leftmostLeg && leftmostLeg.tauMax < params.tauInitial) return []; + + return legs.reverse(); +} + +function normalizeBuildParams( + params: BuildFixedRateOfferChainParams, +): NormalizedBuildFixedRateOfferChainParams { + if (params.side !== "borrow" && params.side !== "lend") { + throw new InvalidOfferParameterError({ + parameter: "side", + value: params.side, + instruction: 'Use "borrow" or "lend".', + }); + } + if (!Number.isFinite(params.targetRate)) { + throw new InvalidOfferParameterError({ + parameter: "targetRate", + value: params.targetRate, + instruction: "Use a finite positive yearly rate.", + }); + } + if (params.targetRate <= 0) { + throw new InvalidOfferParameterError({ + parameter: "targetRate", + value: params.targetRate, + instruction: "Use a positive yearly rate.", + }); + } + + const tickSpacing = normalizeSafeInteger("tickSpacing", params.tickSpacing); + if (tickSpacing <= 0n) { + throw new InvalidOfferParameterError({ + parameter: "tickSpacing", + value: tickSpacing, + instruction: "Use a positive tick spacing.", + }); + } + + const maturityTimestamp = normalizeSafeInteger( + "maturityTimestamp", + params.maturityTimestamp, + ); + const chainStartTimestamp = normalizeSafeInteger( + "chainStartTimestamp", + params.chainStartTimestamp, + ); + const chainEndTimestamp = normalizeSafeInteger( + "chainEndTimestamp", + params.chainEndTimestamp, + ); + if (maturityTimestamp <= chainStartTimestamp) { + throw new InvalidOfferParameterError({ + parameter: "maturityTimestamp", + value: maturityTimestamp, + instruction: "Use a timestamp greater than chainStartTimestamp.", + }); + } + if (chainEndTimestamp <= chainStartTimestamp) { + throw new InvalidOfferParameterError({ + parameter: "chainEndTimestamp", + value: chainEndTimestamp, + instruction: "Use a timestamp greater than chainStartTimestamp.", + }); + } + + return { + side: params.side, + targetRate: params.targetRate, + tickSpacing, + maturityTimestamp, + chainStartTimestamp, + chainEndTimestamp, + }; +} + +function normalizeSafeInteger(parameter: string, value: BigIntish) { + let normalized: bigint; + try { + normalized = BigInt(value); + } catch (cause) { + throw new InvalidOfferParameterError({ + parameter, + value, + instruction: "Use a safe integer value.", + cause, + }); + } + + const numberValue = Number(normalized); + if (!Number.isSafeInteger(numberValue)) { + throw new InvalidOfferParameterError({ + parameter, + value, + instruction: "Use a JavaScript-safe integer value.", + }); + } + + return normalized; +} + +function rateToPriceAtTau(rate: number, tau: number): bigint { + const price = (1 + rate) ** -tau; + if (!Number.isFinite(price) || price <= 0) return 0n; + if (price >= 1) return MathLib.WAD; + + return parseUnits(price.toFixed(18), 18); +} + +function rateTau(tick: bigint, rate: number): number { + const price = Number(formatUnits(TickLib.tickToPrice(tick), 18)); + if (price <= 0 || price >= 1 || rate <= 0) return 0; + + return Math.log(1 / price) / Math.log(1 + rate); +} + +function tauToTimestamp(maturityTimestamp: bigint, tau: number) { + return maturityTimestamp - BigInt(Math.round(tau * SECONDS_PER_YEAR)); +} + +function floorTickToSpacing(tick: bigint, spacing: bigint) { + return tick - (tick % spacing); +} + +function ceilTickToSpacing(tick: bigint, spacing: bigint) { + return ((tick + spacing - 1n) / spacing) * spacing; +} + +function highestDiscountTick(tickSpacing: bigint) { + return floorTickToSpacing( + TickLib.priceToTick(MathLib.WAD, 1n) - 1n, + tickSpacing, + ); +} + +function minBigint(a: bigint, b: bigint) { + return a < b ? a : b; +} diff --git a/packages/midnight-sdk/src/offers/index.ts b/packages/midnight-sdk/src/offers/index.ts index c5b111c30..02206630a 100644 --- a/packages/midnight-sdk/src/offers/index.ts +++ b/packages/midnight-sdk/src/offers/index.ts @@ -1,2 +1,3 @@ export * from "./Offer.js"; +export * from "./OfferChainUtils.js"; export * from "./OfferUtils.js"; diff --git a/packages/midnight-sdk/src/signatures/RatifierUtils.ts b/packages/midnight-sdk/src/signatures/RatifierUtils.ts index a4c80e770..12f2391a7 100644 --- a/packages/midnight-sdk/src/signatures/RatifierUtils.ts +++ b/packages/midnight-sdk/src/signatures/RatifierUtils.ts @@ -19,10 +19,11 @@ function isTreeLike(tree: RatifierTreeInput): tree is TreeLike { function normalizeTree(tree: RatifierTreeInput): TreeLike { if (isTreeLike(tree)) return tree; - const offers: readonly IOffer[] = tree.flatMap((entry) => + const entries = Array.isArray(tree) ? tree : [tree]; + const offers: readonly IOffer[] = entries.flatMap((entry) => "offers" in entry ? Group.from(entry).offers : [Offer.from(entry)], ); - const descriptor = TreeUtils.buildDescriptor(offers); + const descriptor = TreeUtils.buildDescriptor(entries); return { offers, diff --git a/packages/midnight-sdk/src/signatures/SetterRatifierUtils.test.ts b/packages/midnight-sdk/src/signatures/SetterRatifierUtils.test.ts index 44d813ae5..80f7177e6 100644 --- a/packages/midnight-sdk/src/signatures/SetterRatifierUtils.test.ts +++ b/packages/midnight-sdk/src/signatures/SetterRatifierUtils.test.ts @@ -41,7 +41,7 @@ describe("SetterRatifierUtils.ratify", () => { ratifier: addresses.setterRatifier, }); - const items = SetterRatifierUtils.ratify({ tree: [offer] }); + const items = SetterRatifierUtils.ratify({ tree: offer }); const decoded = SetterRatifierUtils.decodeRatifierData( items[0]!.ratifierData, ); diff --git a/packages/midnight-sdk/src/signatures/Tree.ts b/packages/midnight-sdk/src/signatures/Tree.ts index dec8b8606..4b61c8aab 100644 --- a/packages/midnight-sdk/src/signatures/Tree.ts +++ b/packages/midnight-sdk/src/signatures/Tree.ts @@ -137,7 +137,8 @@ export class Tree { * ``` */ public static from(tree: TreeInput): Tree { - return tree instanceof Tree ? tree : Tree.create(tree); + if (tree instanceof Tree) return tree; + return Tree.create(Array.isArray(tree) ? tree : [tree]); } /** diff --git a/packages/midnight-sdk/src/signatures/TreeUtils.test.ts b/packages/midnight-sdk/src/signatures/TreeUtils.test.ts index 9d0677c15..1541fd8ec 100644 --- a/packages/midnight-sdk/src/signatures/TreeUtils.test.ts +++ b/packages/midnight-sdk/src/signatures/TreeUtils.test.ts @@ -299,6 +299,7 @@ describe("Tree.from", () => { expect(Tree.from(tree)).toBe(tree); expect(Tree.from([baseOfferInput({ maxAssets: 0n })])).toBeInstanceOf(Tree); + expect(Tree.from(baseOfferInput({ maxAssets: 0n }))).toBeInstanceOf(Tree); }); }); @@ -383,7 +384,7 @@ describe("TreeUtils.mempoolValidate", () => { await TreeUtils.mempoolValidate({ chainId: 8453, - tree: [offer], + tree: offer, fetch, ratification: { type: "setter" }, }); diff --git a/packages/midnight-sdk/src/signatures/TreeUtils.ts b/packages/midnight-sdk/src/signatures/TreeUtils.ts index 84ac22566..85d4f192e 100644 --- a/packages/midnight-sdk/src/signatures/TreeUtils.ts +++ b/packages/midnight-sdk/src/signatures/TreeUtils.ts @@ -267,7 +267,7 @@ export type TreeCreateParams = readonly GroupInput[]; * console.log(tree); * ``` */ -export type TreeInput = Tree | TreeCreateParams; +export type TreeInput = Tree | TreeCreateParams | GroupInput; /** * Tree-shaped data required by ratifier helpers. @@ -365,7 +365,7 @@ export interface TreeLike { * console.log(tree); * ``` */ -export type RatifierTreeInput = TreeLike | TreeCreateParams; +export type RatifierTreeInput = TreeLike | TreeInput; /** * Optional ratification inputs for {@link Tree.mempoolValidate}. @@ -558,23 +558,25 @@ export namespace TreeUtils { ): Promise { let items: readonly Payload.Item[]; if (params.ratification == null) { - const offers = - "paddedOffers" in params.tree - ? params.tree.offers - : params.tree.flatMap((entry) => - "offers" in entry - ? Group.from(entry).offers - : [Offer.from(entry)], - ); - - if (!("paddedOffers" in params.tree)) { - buildDescriptor(params.tree); + if ("paddedOffers" in params.tree) { + items = params.tree.offers.map((offer) => ({ + offer, + ratifierData: "0x" as const, + })); + } else { + const entries = Array.isArray(params.tree) + ? params.tree + : [params.tree]; + const offers = entries.flatMap((entry) => + "offers" in entry ? Group.from(entry).offers : [Offer.from(entry)], + ); + + buildDescriptor(entries); + items = offers.map((offer) => ({ + offer, + ratifierData: "0x" as const, + })); } - - items = offers.map((offer) => ({ - offer, - ratifierData: "0x" as const, - })); } else if (params.ratification.type === "ecrecover") { if (params.ratification.signature != null) { items = await EcrecoverRatifierUtils.ratify({ diff --git a/packages/morpho-sdk/AGENTS.md b/packages/morpho-sdk/AGENTS.md index a8b24fc34..483abd0e1 100644 --- a/packages/morpho-sdk/AGENTS.md +++ b/packages/morpho-sdk/AGENTS.md @@ -1,6 +1,6 @@ # `packages/morpho-sdk/` -Transaction builders for VaultV1, VaultV2, and Blue. Subfolders carry the layer-scoped detail; this file is the package overview + glossary. +Transaction builders for VaultV1, VaultV2, Blue, and Midnight, plus shared requirement helpers used by protocol flows. Subfolders carry the layer-scoped detail; this file is the package overview + glossary. > Architecture / type / test / doc / release rules apply per the [root `AGENTS.md`](../../AGENTS.md). Subfolder rules: see each `src//AGENTS.md`. @@ -8,6 +8,7 @@ Transaction builders for VaultV1, VaultV2, and Blue. Subfolders carry the layer- - **VaultV1 / VaultV2 deposits** route through bundler3 via GeneralAdapter1 (which enforces `maxSharePrice`, protecting against inflation attacks). VaultV1/V2 `withdraw` and `redeem` are direct vault calls. VaultV2 `forceWithdraw` / `forceRedeem` use `multicall` with `forceDeallocate` calls before the final withdraw/redeem. - **Blue bundled paths** (`supply`, `supplyCollateral`, `borrow`, `supplyCollateralBorrow`, `repay`, `repayWithdrawCollateral`, `withdraw`) route through bundler3 via GeneralAdapter1. `repay` and `withdraw` each accept assets or shares (mutually exclusive); `repayWithdrawCollateral` repays first then withdraws. Loan-asset `supply` supports native wrapping when `loanToken === wNative`; loan-asset `withdraw` supports optional PublicAllocator reallocations to top up market liquidity (same mechanism as `borrow`). +- **Midnight paths** expose lazy action outputs under `client.morpho.midnight(chainId)`. Fixed-rate market taker flows route through Midnight Bundles, direct collateral supply/cancel/redeem route through Midnight, and maker flows return ratify-root requirements plus the mempool payload transaction. Requirement helpers under `src/actions/requirements/midnight` resolve Midnight authorization, Setter ratify-root, and token-pull requirements. - **Bundle composition, native wrapping, and reallocation rules** are canonical in [`src/actions/AGENTS.md`](./src/actions/AGENTS.md). ## Tests diff --git a/packages/morpho-sdk/package.json b/packages/morpho-sdk/package.json index 3e93de561..88f1500c3 100644 --- a/packages/morpho-sdk/package.json +++ b/packages/morpho-sdk/package.json @@ -31,6 +31,7 @@ "./errors": "./src/errors.ts", "./fetch": "./src/fetch.ts", "./format": "./src/format.ts", + "./midnight-api": "./src/midnight-api.ts", "./types": "./src/types.ts", "./utils": "./src/utils.ts" }, @@ -66,6 +67,9 @@ "format": [ "src/format.ts" ], + "midnight-api": [ + "src/midnight-api.ts" + ], "types": [ "src/types.ts" ], @@ -93,6 +97,7 @@ "dependencies": { "@morpho-org/blue-sdk": "workspace:^", "@morpho-org/blue-sdk-viem": "workspace:^", + "@morpho-org/midnight-sdk": "workspace:^", "@morpho-org/morpho-ts": "workspace:^", "zod": "^4.4.3" }, @@ -166,6 +171,11 @@ "import": "./lib/esm/format.js", "require": "./lib/cjs/format.js" }, + "./midnight-api": { + "types": "./lib/esm/midnight-api.d.ts", + "import": "./lib/esm/midnight-api.js", + "require": "./lib/cjs/midnight-api.js" + }, "./types": { "types": "./lib/esm/types.d.ts", "import": "./lib/esm/types.js", diff --git a/packages/morpho-sdk/src/abis.ts b/packages/morpho-sdk/src/abis.ts index 8f72ba4c2..05b9540a8 100644 --- a/packages/morpho-sdk/src/abis.ts +++ b/packages/morpho-sdk/src/abis.ts @@ -26,6 +26,12 @@ export { wrappedBackedTokenAbi, wstEthAbi, } from "@morpho-org/blue-sdk-viem"; +export { + ecrecoverRatifierAbi, + midnightAbi, + midnightBundlesAbi, + setterRatifierAbi, +} from "@morpho-org/midnight-sdk"; /** ABI for the Bundler3 multicall contract. */ export const bundler3Abi = [ diff --git a/packages/morpho-sdk/src/actions/AGENTS.md b/packages/morpho-sdk/src/actions/AGENTS.md index 73d8288e2..81f49c99a 100644 --- a/packages/morpho-sdk/src/actions/AGENTS.md +++ b/packages/morpho-sdk/src/actions/AGENTS.md @@ -7,6 +7,7 @@ Pure synchronous transaction builders. Each action returns a deep-frozen `Transa - `vaultV1/` — VaultV1 (MetaMorpho) `deposit` / `withdraw` / `redeem` / `migrateToV2`. - `vaultV2/` — VaultV2 `deposit` / `withdraw` / `redeem` / `forceWithdraw` / `forceRedeem`. - `blue/` — Morpho Blue `supplyCollateral` / `borrow` / `supplyCollateralBorrow` / `repay` / `repayWithdrawCollateral` / `withdrawCollateral`. Borrow paths support optional shared liquidity via `reallocations`. +- `midnight/` — Midnight fixed-rate direct and bundled transaction encoders plus take normalization for fixed-rate API quote outputs. - `requirements/` — async resolvers that read on-chain state and return what the user must do/sign before a deposit/supply: token approvals, permit/permit2 signature requests, Morpho authorization. - `signatures/` — pure encoders that turn signed requirements into the bundler `Action`s prepended to a bundle (`getTokenRequirementActions` for token permit / permit2 transfers, `getBlueAuthorizationAction` for `setAuthorizationWithSig`). diff --git a/packages/morpho-sdk/src/actions/index.ts b/packages/morpho-sdk/src/actions/index.ts index 62fa7e65c..21aaec5cb 100644 --- a/packages/morpho-sdk/src/actions/index.ts +++ b/packages/morpho-sdk/src/actions/index.ts @@ -1,7 +1,9 @@ export type { BlueActions } from "../entities/blue/index.js"; +export type { MidnightActions } from "../entities/midnight/index.js"; export type { VaultV1Actions } from "../entities/vaultV1/index.js"; export type { VaultV2Actions } from "../entities/vaultV2/index.js"; export * from "./blue/index.js"; +export * from "./midnight/index.js"; export * from "./requirements/index.js"; export * from "./signatures/index.js"; export * from "./vaultV1/index.js"; diff --git a/packages/morpho-sdk/src/actions/midnight/authorization.ts b/packages/morpho-sdk/src/actions/midnight/authorization.ts new file mode 100644 index 000000000..f35f6f8b5 --- /dev/null +++ b/packages/morpho-sdk/src/actions/midnight/authorization.ts @@ -0,0 +1,52 @@ +import { midnightAbi } from "@morpho-org/midnight-sdk"; +import { deepFreeze, getChainAddress } from "@morpho-org/morpho-ts"; +import { type Address, encodeFunctionData } from "viem"; +import { addTransactionMetadata } from "../../helpers/index.js"; +import type { + Metadata, + MidnightAuthorizationAction, + Transaction, +} from "../../types/index.js"; + +/** Parameters for {@link midnightAuthorization}. */ +export interface MidnightAuthorizationParams { + readonly chainId: number; + readonly authorized: Address; + readonly onBehalf: Address; + readonly isAuthorized?: boolean; + readonly metadata?: Metadata; +} + +/** Encodes `Midnight.setIsAuthorized(authorized, true, onBehalf)`. */ +export const midnightAuthorization = ( + params: MidnightAuthorizationParams, +): Readonly> => { + const isAuthorized = params.isAuthorized ?? true; + const midnight = getChainAddress(params.chainId, "midnight"); + + let tx = { + to: midnight, + value: 0n, + data: encodeFunctionData({ + abi: midnightAbi, + functionName: "setIsAuthorized", + args: [params.authorized, isAuthorized, params.onBehalf], + }), + }; + + if (params.metadata) { + tx = addTransactionMetadata(tx, params.metadata); + } + + return deepFreeze({ + ...tx, + action: { + type: "midnightAuthorization", + args: { + authorized: params.authorized, + isAuthorized, + onBehalf: params.onBehalf, + }, + }, + }); +}; diff --git a/packages/morpho-sdk/src/actions/midnight/cancelOffer.test.ts b/packages/morpho-sdk/src/actions/midnight/cancelOffer.test.ts new file mode 100644 index 000000000..a356e82ac --- /dev/null +++ b/packages/morpho-sdk/src/actions/midnight/cancelOffer.test.ts @@ -0,0 +1,26 @@ +import { midnightAbi } from "@morpho-org/midnight-sdk"; +import { decodeFunctionData, type Hex } from "viem"; +import { describe, expect, test } from "vitest"; +import { + midnightAddresses, + midnightChainId, +} from "../../../test/fixtures/midnight.js"; +import { midnightCancelOffer } from "./cancelOffer.js"; + +describe("midnightCancelOffer", () => { + test("default", () => { + const group = + "0x1111111111111111111111111111111111111111111111111111111111111111" as Hex; + const tx = midnightCancelOffer({ + chainId: midnightChainId, + group, + onBehalf: midnightAddresses.taker, + }); + const decoded = decodeFunctionData({ abi: midnightAbi, data: tx.data }); + + expect(tx.to).toBe(midnightAddresses.midnight); + expect(tx.action.args.group).toBe(group); + expect(decoded.functionName).toBe("setConsumed"); + expect(decoded.args[0]).toBe(group); + }); +}); diff --git a/packages/morpho-sdk/src/actions/midnight/cancelOffer.ts b/packages/morpho-sdk/src/actions/midnight/cancelOffer.ts new file mode 100644 index 000000000..ebe0cbaf0 --- /dev/null +++ b/packages/morpho-sdk/src/actions/midnight/cancelOffer.ts @@ -0,0 +1,52 @@ +import { midnightAbi } from "@morpho-org/midnight-sdk"; +import { deepFreeze, getChainAddress } from "@morpho-org/morpho-ts"; +import { type Address, encodeFunctionData, type Hex, maxUint256 } from "viem"; +import { addTransactionMetadata } from "../../helpers/index.js"; +import type { + Metadata, + MidnightCancelOfferAction, + Transaction, +} from "../../types/index.js"; + +/** Parameters for {@link midnightCancelOffer}. */ +export interface MidnightCancelOfferParams { + readonly chainId: number; + readonly group: Hex; + readonly onBehalf: Address; + readonly amount?: bigint; + readonly metadata?: Metadata; +} + +/** Encodes `Midnight.setConsumed(group, maxUint256, onBehalf)`. */ +export const midnightCancelOffer = ( + params: MidnightCancelOfferParams, +): Readonly> => { + const midnight = getChainAddress(params.chainId, "midnight"); + const amount = params.amount ?? maxUint256; + + let tx = { + to: midnight, + value: 0n, + data: encodeFunctionData({ + abi: midnightAbi, + functionName: "setConsumed", + args: [params.group, amount, params.onBehalf], + }), + }; + + if (params.metadata) { + tx = addTransactionMetadata(tx, params.metadata); + } + + return deepFreeze({ + ...tx, + action: { + type: "midnightCancelOffer", + args: { + group: params.group, + amount, + onBehalf: params.onBehalf, + }, + }, + }); +}; diff --git a/packages/morpho-sdk/src/actions/midnight/index.ts b/packages/morpho-sdk/src/actions/midnight/index.ts new file mode 100644 index 000000000..04a187416 --- /dev/null +++ b/packages/morpho-sdk/src/actions/midnight/index.ts @@ -0,0 +1,11 @@ +export * from "./authorization.js"; +export * from "./cancelOffer.js"; +export * from "./mempoolSubmitOffers.js"; +export * from "./redeem.js"; +export * from "./repayWithdrawCollateral.js"; +export * from "./setterRatifierRatifyRoot.js"; +export * from "./supplyCollateral.js"; +export * from "./supplyCollateralTakeBorrow.js"; +export * from "./takeBorrow.js"; +export * from "./takeLend.js"; +export * from "./types.js"; diff --git a/packages/morpho-sdk/src/actions/midnight/mempoolSubmitOffers.test.ts b/packages/morpho-sdk/src/actions/midnight/mempoolSubmitOffers.test.ts new file mode 100644 index 000000000..1026dc6e7 --- /dev/null +++ b/packages/morpho-sdk/src/actions/midnight/mempoolSubmitOffers.test.ts @@ -0,0 +1,56 @@ +import type { Hex } from "viem"; +import { describe, expect, test } from "vitest"; +import { + midnightAddresses, + midnightChainId, +} from "../../../test/fixtures/midnight.js"; +import { mempoolSubmitOffers } from "./mempoolSubmitOffers.js"; + +const group = + "0x1111111111111111111111111111111111111111111111111111111111111111" as Hex; +const root = + "0x2222222222222222222222222222222222222222222222222222222222222222" as Hex; +const payload = "0x12345678" as Hex; + +describe("mempoolSubmitOffers", () => { + test("default", () => { + const tx = mempoolSubmitOffers({ + chainId: midnightChainId, + groups: [group], + root, + maker: midnightAddresses.maker, + ratifier: midnightAddresses.ecrecoverRatifier, + ratifierType: "ecrecover", + offers: 1, + payload, + }); + + expect(tx.to).toBe(midnightAddresses.midnightMempool); + expect(tx.data).toBe(payload); + expect(tx.action.args).toEqual({ + groups: [group], + root, + maker: midnightAddresses.maker, + ratifier: midnightAddresses.ecrecoverRatifier, + ratifierType: "ecrecover", + offers: 1, + }); + }); + + test("behavior: appends metadata", () => { + const tx = mempoolSubmitOffers({ + chainId: midnightChainId, + groups: [group], + root, + maker: midnightAddresses.maker, + ratifier: midnightAddresses.ecrecoverRatifier, + ratifierType: "ecrecover", + offers: 1, + payload, + metadata: { origin: "a1b2c3d4" }, + }); + + expect(tx.action.type).toBe("mempoolSubmitOffers"); + expect(tx.data).toBe(`${payload}a1b2c3d4`); + }); +}); diff --git a/packages/morpho-sdk/src/actions/midnight/mempoolSubmitOffers.ts b/packages/morpho-sdk/src/actions/midnight/mempoolSubmitOffers.ts new file mode 100644 index 000000000..b251b272c --- /dev/null +++ b/packages/morpho-sdk/src/actions/midnight/mempoolSubmitOffers.ts @@ -0,0 +1,53 @@ +import { deepFreeze, getChainAddress } from "@morpho-org/morpho-ts"; +import type { Address, Hex } from "viem"; +import { addTransactionMetadata } from "../../helpers/index.js"; +import type { + MempoolSubmitOffersAction, + Metadata, + Transaction, +} from "../../types/index.js"; + +/** Parameters for {@link mempoolSubmitOffers}. */ +export interface MempoolSubmitOffersParams { + readonly chainId: number; + readonly groups: readonly Hex[]; + readonly root: Hex; + readonly maker: Address; + readonly ratifier: Address; + readonly ratifierType: "ecrecover" | "setter"; + readonly offers: number; + readonly payload: Hex; + readonly metadata?: Metadata; +} + +/** Encodes the Midnight mempool payload submission transaction. */ +export const mempoolSubmitOffers = ( + params: MempoolSubmitOffersParams, +): Readonly> => { + const midnightMempool = getChainAddress(params.chainId, "midnightMempool"); + + let tx = { + to: midnightMempool, + value: 0n, + data: params.payload, + }; + + if (params.metadata) { + tx = addTransactionMetadata(tx, params.metadata); + } + + return deepFreeze({ + ...tx, + action: { + type: "mempoolSubmitOffers" as const, + args: { + groups: params.groups, + root: params.root, + maker: params.maker, + ratifier: params.ratifier, + ratifierType: params.ratifierType, + offers: params.offers, + }, + }, + }); +}; diff --git a/packages/morpho-sdk/src/actions/midnight/redeem.ts b/packages/morpho-sdk/src/actions/midnight/redeem.ts new file mode 100644 index 000000000..5b79890dd --- /dev/null +++ b/packages/morpho-sdk/src/actions/midnight/redeem.ts @@ -0,0 +1,69 @@ +import { + type MarketInput, + MarketUtils, + midnightAbi, +} from "@morpho-org/midnight-sdk"; +import { deepFreeze, getChainAddress } from "@morpho-org/morpho-ts"; +import { type Address, encodeFunctionData } from "viem"; +import { addTransactionMetadata } from "../../helpers/index.js"; +import { + type Metadata, + type MidnightRedeemAction, + NonPositiveMidnightAmountError, + type Transaction, +} from "../../types/index.js"; + +/** Parameters for {@link midnightRedeem}. */ +export interface MidnightRedeemParams { + readonly chainId: number; + readonly market: MarketInput; + readonly units: bigint; + readonly onBehalf: Address; + readonly receiver?: Address; + readonly metadata?: Metadata; +} + +/** Encodes `Midnight.withdraw` for credit redemption. */ +export const midnightRedeem = ( + params: MidnightRedeemParams, +): Readonly> => { + if (params.units <= 0n) { + throw new NonPositiveMidnightAmountError("units", params.units); + } + + const marketId = MarketUtils.toId(params.market); + const midnight = getChainAddress(params.chainId, "midnight"); + const receiver = params.receiver ?? params.onBehalf; + + let tx = { + to: midnight, + value: 0n, + data: encodeFunctionData({ + abi: midnightAbi, + functionName: "withdraw", + args: [ + MarketUtils.toStruct(params.market), + params.units, + params.onBehalf, + receiver, + ], + }), + }; + + if (params.metadata) { + tx = addTransactionMetadata(tx, params.metadata); + } + + return deepFreeze({ + ...tx, + action: { + type: "midnightRedeem", + args: { + market: marketId, + units: params.units, + onBehalf: params.onBehalf, + receiver, + }, + }, + }); +}; diff --git a/packages/morpho-sdk/src/actions/midnight/repayWithdrawCollateral.test.ts b/packages/morpho-sdk/src/actions/midnight/repayWithdrawCollateral.test.ts new file mode 100644 index 000000000..4a12e3970 --- /dev/null +++ b/packages/morpho-sdk/src/actions/midnight/repayWithdrawCollateral.test.ts @@ -0,0 +1,119 @@ +import { + midnightBundlesAbi, + UnknownCollateralIndexError, +} from "@morpho-org/midnight-sdk"; +import { decodeFunctionData, maxUint256, zeroAddress } from "viem"; +import { describe, expect, test } from "vitest"; +import { + midnightAddresses, + midnightChainId, + midnightMarket, + midnightMarketId, +} from "../../../test/fixtures/midnight.js"; +import type { TokenRequirementSignature } from "../../types/index.js"; +import { midnightRepayWithdrawCollateral } from "./repayWithdrawCollateral.js"; +import { PermitKind } from "./types.js"; + +describe("midnightRepayWithdrawCollateral", () => { + test("default", () => { + const tx = midnightRepayWithdrawCollateral({ + chainId: midnightChainId, + market: midnightMarket, + repayAssets: 1_000n, + withdrawCollateralAssets: 2_000n, + onBehalf: midnightAddresses.taker, + deadline: maxUint256, + }); + const decoded = decodeFunctionData({ + abi: midnightBundlesAbi, + data: tx.data, + }); + + expect(tx.to).toBe(midnightAddresses.midnightBundles); + expect(tx.action.args).toEqual({ + market: midnightMarketId, + repayAssets: 1_000n, + collateralWithdrawals: 1, + onBehalf: midnightAddresses.taker, + collateralReceiver: midnightAddresses.taker, + referralFeePct: 0n, + referralFeeRecipient: zeroAddress, + deadline: maxUint256, + }); + expect(decoded.functionName).toBe( + "midnightBundlesV1RepayAndWithdrawCollateral", + ); + expect(decoded.args[1]).toBe(1_000n); + expect(decoded.args?.[3]).toEqual({ + kind: PermitKind.None, + data: "0x", + }); + }); + + test("behavior: encodes loan token permit", () => { + const tx = midnightRepayWithdrawCollateral({ + chainId: midnightChainId, + market: midnightMarket, + repayAssets: 1_000n, + withdrawCollateralAssets: 0n, + onBehalf: midnightAddresses.taker, + deadline: maxUint256, + signatures: [ + { + action: { + type: "permit2Transfer", + args: { + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + deadline: 123n, + }, + }, + args: { + owner: midnightAddresses.taker, + nonce: 42n, + asset: midnightAddresses.loanToken, + signature: "0x1234", + amount: 1_000n, + deadline: 123n, + }, + } satisfies TokenRequirementSignature, + ], + }); + const decoded = decodeFunctionData({ + abi: midnightBundlesAbi, + data: tx.data, + }); + + expect(decoded.args?.[3]).toMatchObject({ + kind: PermitKind.Permit2, + }); + }); + + test("error: UnknownCollateralIndexError for default withdrawal", () => { + expect(() => + midnightRepayWithdrawCollateral({ + chainId: midnightChainId, + market: midnightMarket, + repayAssets: 0n, + withdrawCollateralAssets: 2_000n, + collateralIndex: 1n, + onBehalf: midnightAddresses.taker, + deadline: maxUint256, + }), + ).toThrow(UnknownCollateralIndexError); + }); + + test("error: UnknownCollateralIndexError for listed withdrawal", () => { + expect(() => + midnightRepayWithdrawCollateral({ + chainId: midnightChainId, + market: midnightMarket, + repayAssets: 1_000n, + withdrawCollateralAssets: 0n, + collateralWithdrawals: [{ collateralIndex: 1n, assets: 2_000n }], + onBehalf: midnightAddresses.taker, + deadline: maxUint256, + }), + ).toThrow(UnknownCollateralIndexError); + }); +}); diff --git a/packages/morpho-sdk/src/actions/midnight/repayWithdrawCollateral.ts b/packages/morpho-sdk/src/actions/midnight/repayWithdrawCollateral.ts new file mode 100644 index 000000000..a9845018a --- /dev/null +++ b/packages/morpho-sdk/src/actions/midnight/repayWithdrawCollateral.ts @@ -0,0 +1,154 @@ +import { + type MarketInput, + MarketUtils, + midnightBundlesAbi, +} from "@morpho-org/midnight-sdk"; +import { deepFreeze, getChainAddress } from "@morpho-org/morpho-ts"; +import { type Address, encodeFunctionData, zeroAddress } from "viem"; +import { addTransactionMetadata } from "../../helpers/index.js"; +import { + type AnyRequirementSignature, + type Metadata, + type MidnightRepayWithdrawCollateralAction, + NegativeMidnightAmountError, + NonPositiveMidnightAmountError, + type Transaction, +} from "../../types/index.js"; +import { getMidnightTokenPermit } from "../signatures/getMidnightTokenPermit.js"; +import type { MidnightCollateralWithdrawal } from "./types.js"; + +/** Parameters for {@link midnightRepayWithdrawCollateral}. */ +export interface MidnightRepayWithdrawCollateralParams { + readonly chainId: number; + readonly market: MarketInput; + readonly repayAssets: bigint; + readonly withdrawCollateralAssets: bigint; + readonly onBehalf: Address; + readonly receiver?: Address; + readonly collateralReceiver?: Address; + readonly collateralIndex?: bigint; + readonly collateralWithdrawals?: readonly MidnightCollateralWithdrawal[]; + readonly referralFeePct?: bigint; + readonly referralFeeRecipient?: Address; + /** Bundle execution deadline timestamp. Pass `maxUint256` explicitly for no expiry. */ + readonly deadline: bigint; + readonly signatures?: + | AnyRequirementSignature + | readonly AnyRequirementSignature[]; + readonly metadata?: Metadata; +} + +/** Encodes the repay and/or withdraw-collateral Midnight bundle. */ +export const midnightRepayWithdrawCollateral = ( + params: MidnightRepayWithdrawCollateralParams, +): Readonly> => { + if (params.repayAssets < 0n) { + throw new NegativeMidnightAmountError("repayAssets", params.repayAssets); + } + if (params.withdrawCollateralAssets < 0n) { + throw new NegativeMidnightAmountError( + "withdrawCollateralAssets", + params.withdrawCollateralAssets, + ); + } + if ((params.referralFeePct ?? 0n) < 0n) { + throw new NegativeMidnightAmountError( + "referralFeePct", + params.referralFeePct ?? 0n, + ); + } + if (params.deadline < 0n) { + throw new NegativeMidnightAmountError("deadline", params.deadline); + } + const collateralWithdrawals = + params.collateralWithdrawals ?? + (params.withdrawCollateralAssets > 0n + ? [ + { + collateralIndex: params.collateralIndex ?? 0n, + assets: params.withdrawCollateralAssets, + }, + ] + : []); + for (const [index, withdrawal] of collateralWithdrawals.entries()) { + if (withdrawal.collateralIndex < 0n) { + throw new NegativeMidnightAmountError( + `collateralWithdrawals[${index}].collateralIndex`, + withdrawal.collateralIndex, + ); + } + if (withdrawal.assets < 0n) { + throw new NegativeMidnightAmountError( + `collateralWithdrawals[${index}].assets`, + withdrawal.assets, + ); + } + } + if ( + params.repayAssets === 0n && + collateralWithdrawals.every((withdrawal) => withdrawal.assets === 0n) + ) { + throw new NonPositiveMidnightAmountError("repay or withdraw amount", 0n); + } + + const marketId = MarketUtils.toId(params.market); + const market = MarketUtils.toStruct(params.market); + for (const withdrawal of collateralWithdrawals) { + if (withdrawal.assets > 0n) { + // Validate that every positive withdrawal targets a configured collateral. + MarketUtils.getCollateralByIndex(market, withdrawal.collateralIndex); + } + } + const midnightBundles = getChainAddress(params.chainId, "midnightBundles"); + const collateralReceiver = + params.collateralReceiver ?? params.receiver ?? params.onBehalf; + const referralFeePct = params.referralFeePct ?? 0n; + const referralFeeRecipient = params.referralFeeRecipient ?? zeroAddress; + + let tx = { + to: midnightBundles, + value: 0n, + data: encodeFunctionData({ + abi: midnightBundlesAbi, + functionName: "midnightBundlesV1RepayAndWithdrawCollateral", + args: [ + market, + params.repayAssets, + params.onBehalf, + getMidnightTokenPermit({ + token: market.loanToken, + owner: params.onBehalf, + spender: midnightBundles, + amount: params.repayAssets, + signatures: params.signatures, + }), + collateralWithdrawals, + collateralReceiver, + referralFeePct, + referralFeeRecipient, + params.deadline, + ], + }), + }; + + if (params.metadata) { + tx = addTransactionMetadata(tx, params.metadata); + } + + return deepFreeze({ + ...tx, + action: { + type: "midnightRepayWithdrawCollateral", + args: { + market: marketId, + repayAssets: params.repayAssets, + collateralWithdrawals: collateralWithdrawals.length, + onBehalf: params.onBehalf, + collateralReceiver, + referralFeePct, + referralFeeRecipient, + deadline: params.deadline, + }, + }, + }); +}; diff --git a/packages/morpho-sdk/src/actions/midnight/setterRatifierRatifyRoot.ts b/packages/morpho-sdk/src/actions/midnight/setterRatifierRatifyRoot.ts new file mode 100644 index 000000000..e29c3f921 --- /dev/null +++ b/packages/morpho-sdk/src/actions/midnight/setterRatifierRatifyRoot.ts @@ -0,0 +1,72 @@ +import { setterRatifierAbi } from "@morpho-org/midnight-sdk"; +import { deepFreeze, getChainAddress } from "@morpho-org/morpho-ts"; +import { type Address, encodeFunctionData, type Hex } from "viem"; +import { addTransactionMetadata } from "../../helpers/index.js"; +import type { + Metadata, + SetterRatifierRatifyRootAction, + Transaction, +} from "../../types/index.js"; + +/** Parameters for {@link setterRatifierRatifyRoot}. */ +export interface SetterRatifierRatifyRootParams { + /** Chain id used to resolve the SetterRatifier deployment. */ + readonly chainId: number; + /** Maker whose offer-tree root is being ratified. */ + readonly maker: Address; + /** Offer-tree root to ratify or unratify. */ + readonly root: Hex; + /** Whether the root should be ratified. Defaults to `true`. */ + readonly isRootRatified?: boolean; + /** Optional metadata appended to the transaction calldata. */ + readonly metadata?: Metadata; +} + +/** + * Encodes a SetterRatifier root-ratification transaction. + * + * @param params - SetterRatifier ratify-root parameters. + * @returns A deep-frozen transaction calling `SetterRatifier.setIsRootRatified`. + * @example + * ```ts + * import { setterRatifierRatifyRoot } from "@morpho-org/morpho-sdk"; + * + * const tx = setterRatifierRatifyRoot({ + * chainId: 8453, + * maker, + * root, + * }); + * ``` + */ +export const setterRatifierRatifyRoot = ( + params: SetterRatifierRatifyRootParams, +): Readonly> => { + const isRootRatified = params.isRootRatified ?? true; + const setterRatifier = getChainAddress(params.chainId, "setterRatifier"); + + let tx = { + to: setterRatifier, + value: 0n, + data: encodeFunctionData({ + abi: setterRatifierAbi, + functionName: "setIsRootRatified", + args: [params.maker, params.root, isRootRatified], + }), + }; + + if (params.metadata) { + tx = addTransactionMetadata(tx, params.metadata); + } + + return deepFreeze({ + ...tx, + action: { + type: "setterRatifierRatifyRoot", + args: { + maker: params.maker, + root: params.root, + isRootRatified, + }, + }, + }); +}; diff --git a/packages/morpho-sdk/src/actions/midnight/supplyCollateral.test.ts b/packages/morpho-sdk/src/actions/midnight/supplyCollateral.test.ts new file mode 100644 index 000000000..a08af3031 --- /dev/null +++ b/packages/morpho-sdk/src/actions/midnight/supplyCollateral.test.ts @@ -0,0 +1,46 @@ +import { + midnightAbi, + UnknownCollateralIndexError, +} from "@morpho-org/midnight-sdk"; +import { decodeFunctionData } from "viem"; +import { describe, expect, test } from "vitest"; +import { + midnightAddresses, + midnightChainId, + midnightMarket, + midnightMarketId, +} from "../../../test/fixtures/midnight.js"; +import { midnightSupplyCollateral } from "./supplyCollateral.js"; + +describe("midnightSupplyCollateral", () => { + test("default", () => { + const tx = midnightSupplyCollateral({ + chainId: midnightChainId, + market: midnightMarket, + assets: 2_000n, + onBehalf: midnightAddresses.taker, + }); + const decoded = decodeFunctionData({ abi: midnightAbi, data: tx.data }); + + expect(tx.to).toBe(midnightAddresses.midnight); + expect(tx.action.args).toEqual({ + market: midnightMarketId, + collateralIndex: 0n, + assets: 2_000n, + onBehalf: midnightAddresses.taker, + }); + expect(decoded.functionName).toBe("supplyCollateral"); + }); + + test("error: UnknownCollateralIndexError", () => { + expect(() => + midnightSupplyCollateral({ + chainId: midnightChainId, + market: midnightMarket, + collateralIndex: 1n, + assets: 2_000n, + onBehalf: midnightAddresses.taker, + }), + ).toThrow(UnknownCollateralIndexError); + }); +}); diff --git a/packages/morpho-sdk/src/actions/midnight/supplyCollateral.ts b/packages/morpho-sdk/src/actions/midnight/supplyCollateral.ts new file mode 100644 index 000000000..26741362e --- /dev/null +++ b/packages/morpho-sdk/src/actions/midnight/supplyCollateral.ts @@ -0,0 +1,71 @@ +import { + type MarketInput, + MarketUtils, + midnightAbi, +} from "@morpho-org/midnight-sdk"; +import { deepFreeze, getChainAddress } from "@morpho-org/morpho-ts"; +import { type Address, encodeFunctionData } from "viem"; +import { addTransactionMetadata } from "../../helpers/index.js"; +import { + type Metadata, + type MidnightSupplyCollateralAction, + NonPositiveMidnightAmountError, + type Transaction, +} from "../../types/index.js"; + +/** Parameters for {@link midnightSupplyCollateral}. */ +export interface MidnightSupplyCollateralParams { + readonly chainId: number; + readonly market: MarketInput; + readonly collateralIndex?: bigint; + readonly assets: bigint; + readonly onBehalf: Address; + readonly metadata?: Metadata; +} + +/** Encodes `Midnight.supplyCollateral`. */ +export const midnightSupplyCollateral = ( + params: MidnightSupplyCollateralParams, +): Readonly> => { + if (params.assets <= 0n) { + throw new NonPositiveMidnightAmountError("assets", params.assets); + } + + const marketId = MarketUtils.toId(params.market); + const midnight = getChainAddress(params.chainId, "midnight"); + const collateralIndex = params.collateralIndex ?? 0n; + // Validate that the supplied collateral index is configured before encoding. + MarketUtils.getCollateralByIndex(params.market, collateralIndex); + + let tx = { + to: midnight, + value: 0n, + data: encodeFunctionData({ + abi: midnightAbi, + functionName: "supplyCollateral", + args: [ + MarketUtils.toStruct(params.market), + collateralIndex, + params.assets, + params.onBehalf, + ], + }), + }; + + if (params.metadata) { + tx = addTransactionMetadata(tx, params.metadata); + } + + return deepFreeze({ + ...tx, + action: { + type: "midnightSupplyCollateral", + args: { + market: marketId, + collateralIndex, + assets: params.assets, + onBehalf: params.onBehalf, + }, + }, + }); +}; diff --git a/packages/morpho-sdk/src/actions/midnight/supplyCollateralTakeBorrow.test.ts b/packages/morpho-sdk/src/actions/midnight/supplyCollateralTakeBorrow.test.ts new file mode 100644 index 000000000..e6ba984df --- /dev/null +++ b/packages/morpho-sdk/src/actions/midnight/supplyCollateralTakeBorrow.test.ts @@ -0,0 +1,172 @@ +import { + midnightBundlesAbi, + UnknownCollateralIndexError, +} from "@morpho-org/midnight-sdk"; +import { decodeFunctionData, type Hex, maxUint256 } from "viem"; +import { describe, expect, test } from "vitest"; +import { + midnightAddresses, + midnightApiTake, + midnightChainId, + midnightMarket, + midnightOtherMarket, +} from "../../../test/fixtures/midnight.js"; +import { + EmptyMidnightTakeableOffersError, + MidnightOfferSideMismatchError, + MidnightTakeableOfferMarketMismatchError, + type RequirementSignature, +} from "../../types/index.js"; +import { midnightSupplyCollateralTakeBorrow } from "./supplyCollateralTakeBorrow.js"; +import { PermitKind } from "./types.js"; + +const signature = `0x${"11".repeat(32)}${"22".repeat(32)}1b` as Hex; + +describe("midnightSupplyCollateralTakeBorrow", () => { + test("default", () => { + const takeableOffers = [midnightApiTake({ buy: true })]; + const tx = midnightSupplyCollateralTakeBorrow({ + chainId: midnightChainId, + market: midnightMarket, + collateralAssets: 2_000n, + loanAssets: 1_000n, + maxUnits: 1_100n, + taker: midnightAddresses.taker, + takeableOffers, + deadline: maxUint256, + }); + const decoded = decodeFunctionData({ + abi: midnightBundlesAbi, + data: tx.data, + }); + + expect(tx.to).toBe(midnightAddresses.midnightBundles); + expect(tx.action.args.loanAssets).toBe(1_000n); + expect(tx.action.type).toBe("midnightSupplyCollateralTakeBorrow"); + expect(decoded.functionName).toBe( + "midnightBundlesV1SupplyCollateralAndSellWithAssetsTarget", + ); + expect(decoded.args[0]).toBe(1_000n); + expect(decoded.args[1]).toBe(1_100n); + expect(decoded.args?.[5]).toMatchObject([ + { + permit: { + kind: PermitKind.None, + data: "0x", + }, + }, + ]); + }); + + test("behavior: encodes collateral token permit", () => { + const tx = midnightSupplyCollateralTakeBorrow({ + chainId: midnightChainId, + market: midnightMarket, + collateralAssets: 2_000n, + loanAssets: 1_000n, + maxUnits: 1_100n, + taker: midnightAddresses.taker, + takeableOffers: [midnightApiTake({ buy: true })], + deadline: maxUint256, + signatures: [ + { + action: { + type: "permit", + args: { + spender: midnightAddresses.midnightBundles, + amount: 2_000n, + deadline: 123n, + }, + }, + args: { + owner: midnightAddresses.taker, + nonce: 0n, + asset: midnightAddresses.collateralToken, + signature, + amount: 2_000n, + deadline: 123n, + }, + } satisfies RequirementSignature, + ], + }); + const decoded = decodeFunctionData({ + abi: midnightBundlesAbi, + data: tx.data, + }); + + expect(decoded.args?.[5]).toMatchObject([ + { + permit: { + kind: PermitKind.ERC2612, + }, + }, + ]); + }); + + test("error: EmptyMidnightTakeableOffersError", () => { + expect(() => + midnightSupplyCollateralTakeBorrow({ + chainId: midnightChainId, + market: midnightMarket, + collateralAssets: 2_000n, + loanAssets: 1_000n, + maxUnits: 1_100n, + taker: midnightAddresses.taker, + takeableOffers: [], + deadline: maxUint256, + }), + ).toThrow(EmptyMidnightTakeableOffersError); + }); + + test("error: MidnightOfferSideMismatchError", () => { + const takeableOffers = [midnightApiTake()]; + + expect(() => + midnightSupplyCollateralTakeBorrow({ + chainId: midnightChainId, + market: midnightMarket, + collateralAssets: 2_000n, + loanAssets: 1_000n, + maxUnits: 1_100n, + taker: midnightAddresses.taker, + takeableOffers, + deadline: maxUint256, + }), + ).toThrow(MidnightOfferSideMismatchError); + }); + + test("error: MidnightTakeableOfferMarketMismatchError", () => { + const takeableOffers = [ + midnightApiTake({ buy: true, market: midnightOtherMarket }), + ]; + + expect(() => + midnightSupplyCollateralTakeBorrow({ + chainId: midnightChainId, + market: midnightMarket, + collateralAssets: 2_000n, + loanAssets: 1_000n, + maxUnits: 1_100n, + taker: midnightAddresses.taker, + takeableOffers, + deadline: maxUint256, + }), + ).toThrow(MidnightTakeableOfferMarketMismatchError); + }); + + test("error: UnknownCollateralIndexError", () => { + expect(() => + midnightSupplyCollateralTakeBorrow({ + chainId: midnightChainId, + market: midnightMarket, + collateralAssets: 2_000n, + loanAssets: 1_000n, + maxUnits: 1_100n, + taker: midnightAddresses.taker, + collateralIndex: 1n, + takeableOffers: [midnightApiTake({ buy: true })], + deadline: maxUint256, + }), + ).toThrow(UnknownCollateralIndexError); + }); +}); diff --git a/packages/morpho-sdk/src/actions/midnight/supplyCollateralTakeBorrow.ts b/packages/morpho-sdk/src/actions/midnight/supplyCollateralTakeBorrow.ts new file mode 100644 index 000000000..bb5e3909f --- /dev/null +++ b/packages/morpho-sdk/src/actions/midnight/supplyCollateralTakeBorrow.ts @@ -0,0 +1,152 @@ +import { MarketUtils, midnightBundlesAbi } from "@morpho-org/midnight-sdk"; +import { deepFreeze, getChainAddress } from "@morpho-org/morpho-ts"; +import { encodeFunctionData, maxUint256, zeroAddress } from "viem"; +import { addTransactionMetadata } from "../../helpers/index.js"; +import { validateOfferSides } from "../../helpers/validateOfferSides.js"; +import { + type AnyRequirementSignature, + EmptyMidnightTakeableOffersError, + type MidnightSupplyCollateralTakeBorrowAction, + MidnightTakeableOfferMarketMismatchError, + NegativeMidnightAmountError, + NonPositiveMidnightAmountError, + type Transaction, +} from "../../types/index.js"; +import { getMidnightTokenPermit } from "../signatures/getMidnightTokenPermit.js"; +import type { MidnightTakeBorrowParams } from "./takeBorrow.js"; +import type { MidnightCollateralSupply } from "./types.js"; + +/** Parameters for {@link midnightSupplyCollateralTakeBorrow}. */ +export interface MidnightSupplyCollateralTakeBorrowParams + extends MidnightTakeBorrowParams { + readonly collateralAssets: bigint; + readonly collateralIndex?: bigint; + readonly signatures?: + | AnyRequirementSignature + | readonly AnyRequirementSignature[]; +} + +/** Encodes the supply-collateral-and-take-borrow Midnight bundle. */ +export const midnightSupplyCollateralTakeBorrow = ( + params: MidnightSupplyCollateralTakeBorrowParams, +): Readonly> => { + if (params.collateralAssets <= 0n) { + throw new NonPositiveMidnightAmountError( + "collateralAssets", + params.collateralAssets, + ); + } + if (params.loanAssets <= 0n) { + throw new NonPositiveMidnightAmountError("loanAssets", params.loanAssets); + } + if (params.maxUnits < 0n) { + throw new NegativeMidnightAmountError("maxUnits", params.maxUnits); + } + if ((params.referralFeePct ?? 0n) < 0n) { + throw new NegativeMidnightAmountError( + "referralFeePct", + params.referralFeePct ?? 0n, + ); + } + if ((params.maxContinuousFee ?? maxUint256) < 0n) { + throw new NegativeMidnightAmountError( + "maxContinuousFee", + params.maxContinuousFee ?? maxUint256, + ); + } + if (params.deadline < 0n) { + throw new NegativeMidnightAmountError("deadline", params.deadline); + } + if (params.takeableOffers.length === 0) { + throw new EmptyMidnightTakeableOffersError(); + } + + const marketId = MarketUtils.toId(params.market); + validateOfferSides( + params.takeableOffers.map((take) => take.offer), + true, + ); + for (const [index, take] of params.takeableOffers.entries()) { + const actualMarketId = MarketUtils.toId(take.offer.market); + if (actualMarketId.toLowerCase() !== marketId.toLowerCase()) { + throw new MidnightTakeableOfferMarketMismatchError({ + index, + expectedMarket: marketId, + actualMarket: actualMarketId, + }); + } + } + + const midnightBundles = getChainAddress(params.chainId, "midnightBundles"); + const collateralIndex = params.collateralIndex ?? 0n; + const collateral = MarketUtils.getCollateralByIndex( + params.market, + collateralIndex, + ); + const collateralSupplies: readonly MidnightCollateralSupply[] = [ + { + collateralIndex, + assets: params.collateralAssets, + permit: getMidnightTokenPermit({ + token: collateral.token, + owner: params.taker, + spender: midnightBundles, + amount: params.collateralAssets, + signatures: params.signatures, + }), + }, + ]; + const reduceOnly = params.reduceOnly ?? false; + const receiver = params.receiver ?? params.taker; + const referralFeePct = params.referralFeePct ?? 0n; + const referralFeeRecipient = params.referralFeeRecipient ?? zeroAddress; + const maxContinuousFee = params.maxContinuousFee ?? maxUint256; + + let tx = { + to: midnightBundles, + value: 0n, + data: encodeFunctionData({ + abi: midnightBundlesAbi, + functionName: "midnightBundlesV1SupplyCollateralAndSellWithAssetsTarget", + args: [ + params.loanAssets, + params.maxUnits, + params.taker, + reduceOnly, + receiver, + collateralSupplies, + params.takeableOffers, + referralFeePct, + referralFeeRecipient, + maxContinuousFee, + params.deadline, + ], + }), + }; + + if (params.metadata) { + tx = addTransactionMetadata(tx, params.metadata); + } + + return deepFreeze({ + ...tx, + action: { + type: "midnightSupplyCollateralTakeBorrow", + args: { + market: marketId, + collateralAssets: params.collateralAssets, + loanAssets: params.loanAssets, + maxUnits: params.maxUnits, + taker: params.taker, + reduceOnly, + receiver, + collateralSupplies: collateralSupplies.length, + takeableOffers: params.takeableOffers.length, + referralFeePct, + referralFeeRecipient, + maxContinuousFee, + deadline: params.deadline, + }, + }, + }); +}; diff --git a/packages/morpho-sdk/src/actions/midnight/takeBorrow.test.ts b/packages/morpho-sdk/src/actions/midnight/takeBorrow.test.ts new file mode 100644 index 000000000..deed401bc --- /dev/null +++ b/packages/morpho-sdk/src/actions/midnight/takeBorrow.test.ts @@ -0,0 +1,92 @@ +import { midnightBundlesAbi } from "@morpho-org/midnight-sdk"; +import { decodeFunctionData, maxUint256 } from "viem"; +import { describe, expect, test } from "vitest"; +import { + midnightAddresses, + midnightApiTake, + midnightChainId, + midnightMarket, + midnightOtherMarket, +} from "../../../test/fixtures/midnight.js"; +import { + EmptyMidnightTakeableOffersError, + MidnightOfferSideMismatchError, + MidnightTakeableOfferMarketMismatchError, +} from "../../types/index.js"; +import { midnightTakeBorrow } from "./takeBorrow.js"; + +describe("midnightTakeBorrow", () => { + test("default", () => { + const takeableOffers = [midnightApiTake({ buy: true })]; + const tx = midnightTakeBorrow({ + chainId: midnightChainId, + market: midnightMarket, + loanAssets: 1_000n, + maxUnits: 1_100n, + taker: midnightAddresses.taker, + takeableOffers, + deadline: maxUint256, + }); + const decoded = decodeFunctionData({ + abi: midnightBundlesAbi, + data: tx.data, + }); + + expect(tx.to).toBe(midnightAddresses.midnightBundles); + expect(tx.action.args.loanAssets).toBe(1_000n); + expect(decoded.functionName).toBe( + "midnightBundlesV1SupplyCollateralAndSellWithAssetsTarget", + ); + expect(decoded.args[0]).toBe(1_000n); + expect(decoded.args[1]).toBe(1_100n); + expect(decoded.args?.[5]).toEqual([]); + }); + + test("error: EmptyMidnightTakeableOffersError", () => { + expect(() => + midnightTakeBorrow({ + chainId: midnightChainId, + market: midnightMarket, + loanAssets: 1_000n, + maxUnits: 1_100n, + taker: midnightAddresses.taker, + takeableOffers: [], + deadline: maxUint256, + }), + ).toThrow(EmptyMidnightTakeableOffersError); + }); + + test("error: MidnightOfferSideMismatchError", () => { + const takeableOffers = [midnightApiTake()]; + + expect(() => + midnightTakeBorrow({ + chainId: midnightChainId, + market: midnightMarket, + loanAssets: 1_000n, + maxUnits: 1_100n, + taker: midnightAddresses.taker, + takeableOffers, + deadline: maxUint256, + }), + ).toThrow(MidnightOfferSideMismatchError); + }); + + test("error: MidnightTakeableOfferMarketMismatchError", () => { + const takeableOffers = [ + midnightApiTake({ buy: true, market: midnightOtherMarket }), + ]; + + expect(() => + midnightTakeBorrow({ + chainId: midnightChainId, + market: midnightMarket, + loanAssets: 1_000n, + maxUnits: 1_100n, + taker: midnightAddresses.taker, + takeableOffers, + deadline: maxUint256, + }), + ).toThrow(MidnightTakeableOfferMarketMismatchError); + }); +}); diff --git a/packages/morpho-sdk/src/actions/midnight/takeBorrow.ts b/packages/morpho-sdk/src/actions/midnight/takeBorrow.ts new file mode 100644 index 000000000..ab9bdf425 --- /dev/null +++ b/packages/morpho-sdk/src/actions/midnight/takeBorrow.ts @@ -0,0 +1,142 @@ +import { + type MarketInput, + MarketUtils, + midnightBundlesAbi, +} from "@morpho-org/midnight-sdk"; +import { deepFreeze, getChainAddress } from "@morpho-org/morpho-ts"; +import { + type Address, + encodeFunctionData, + maxUint256, + zeroAddress, +} from "viem"; +import { addTransactionMetadata } from "../../helpers/index.js"; +import { validateOfferSides } from "../../helpers/validateOfferSides.js"; +import { + EmptyMidnightTakeableOffersError, + type Metadata, + MidnightTakeableOfferMarketMismatchError, + type MidnightTakeBorrowAction, + NegativeMidnightAmountError, + NonPositiveMidnightAmountError, + type Transaction, +} from "../../types/index.js"; +import type { MidnightTakeableOffer } from "./types.js"; + +/** Parameters for {@link midnightTakeBorrow}. */ +export interface MidnightTakeBorrowParams { + readonly chainId: number; + readonly market: MarketInput; + readonly loanAssets: bigint; + readonly maxUnits: bigint; + readonly taker: Address; + readonly reduceOnly?: boolean; + readonly receiver?: Address; + readonly referralFeePct?: bigint; + readonly referralFeeRecipient?: Address; + readonly maxContinuousFee?: bigint; + /** Bundle execution deadline timestamp. Pass `maxUint256` explicitly for no expiry. */ + readonly deadline: bigint; + readonly takeableOffers: readonly MidnightTakeableOffer[]; + readonly metadata?: Metadata; +} + +/** Encodes the take-borrow Midnight bundle. */ +export const midnightTakeBorrow = ( + params: MidnightTakeBorrowParams, +): Readonly> => { + if (params.loanAssets <= 0n) { + throw new NonPositiveMidnightAmountError("loanAssets", params.loanAssets); + } + if (params.maxUnits < 0n) { + throw new NegativeMidnightAmountError("maxUnits", params.maxUnits); + } + if ((params.referralFeePct ?? 0n) < 0n) { + throw new NegativeMidnightAmountError( + "referralFeePct", + params.referralFeePct ?? 0n, + ); + } + if ((params.maxContinuousFee ?? maxUint256) < 0n) { + throw new NegativeMidnightAmountError( + "maxContinuousFee", + params.maxContinuousFee ?? maxUint256, + ); + } + if (params.deadline < 0n) { + throw new NegativeMidnightAmountError("deadline", params.deadline); + } + if (params.takeableOffers.length === 0) { + throw new EmptyMidnightTakeableOffersError(); + } + + const marketId = MarketUtils.toId(params.market); + validateOfferSides( + params.takeableOffers.map((take) => take.offer), + true, + ); + for (const [index, take] of params.takeableOffers.entries()) { + const actualMarketId = MarketUtils.toId(take.offer.market); + if (actualMarketId.toLowerCase() !== marketId.toLowerCase()) { + throw new MidnightTakeableOfferMarketMismatchError({ + index, + expectedMarket: marketId, + actualMarket: actualMarketId, + }); + } + } + + const midnightBundles = getChainAddress(params.chainId, "midnightBundles"); + const reduceOnly = params.reduceOnly ?? false; + const receiver = params.receiver ?? params.taker; + const referralFeePct = params.referralFeePct ?? 0n; + const referralFeeRecipient = params.referralFeeRecipient ?? zeroAddress; + const maxContinuousFee = params.maxContinuousFee ?? maxUint256; + + let tx = { + to: midnightBundles, + value: 0n, + data: encodeFunctionData({ + abi: midnightBundlesAbi, + functionName: "midnightBundlesV1SupplyCollateralAndSellWithAssetsTarget", + args: [ + params.loanAssets, + params.maxUnits, + params.taker, + reduceOnly, + receiver, + [], + params.takeableOffers, + referralFeePct, + referralFeeRecipient, + maxContinuousFee, + params.deadline, + ], + }), + }; + + if (params.metadata) { + tx = addTransactionMetadata(tx, params.metadata); + } + + return deepFreeze({ + ...tx, + action: { + type: "midnightTakeBorrow", + args: { + market: marketId, + loanAssets: params.loanAssets, + maxUnits: params.maxUnits, + taker: params.taker, + reduceOnly, + receiver, + collateralSupplies: 0, + takeableOffers: params.takeableOffers.length, + referralFeePct, + referralFeeRecipient, + maxContinuousFee, + deadline: params.deadline, + }, + }, + }); +}; diff --git a/packages/morpho-sdk/src/actions/midnight/takeLend.test.ts b/packages/morpho-sdk/src/actions/midnight/takeLend.test.ts new file mode 100644 index 000000000..a77c9eda6 --- /dev/null +++ b/packages/morpho-sdk/src/actions/midnight/takeLend.test.ts @@ -0,0 +1,149 @@ +import { midnightBundlesAbi } from "@morpho-org/midnight-sdk"; +import { decodeFunctionData, maxUint256, zeroAddress } from "viem"; +import { describe, expect, test } from "vitest"; +import { + midnightAddresses, + midnightApiTake, + midnightChainId, + midnightMarket, + midnightMarketId, + midnightOtherMarket, +} from "../../../test/fixtures/midnight.js"; +import { + EmptyMidnightTakeableOffersError, + MidnightOfferSideMismatchError, + MidnightTakeableOfferMarketMismatchError, + type TokenRequirementSignature, +} from "../../types/index.js"; +import { midnightTakeLend } from "./takeLend.js"; +import { PermitKind } from "./types.js"; + +describe("midnightTakeLend", () => { + test("default", () => { + const takeableOffers = [midnightApiTake()]; + const tx = midnightTakeLend({ + chainId: midnightChainId, + market: midnightMarket, + assets: 1_000n, + minUnits: 900n, + taker: midnightAddresses.taker, + takeableOffers, + deadline: maxUint256, + }); + const decoded = decodeFunctionData({ + abi: midnightBundlesAbi, + data: tx.data, + }); + + expect(tx.to).toBe(midnightAddresses.midnightBundles); + expect(tx.action.args).toEqual({ + market: midnightMarketId, + assets: 1_000n, + minUnits: 900n, + taker: midnightAddresses.taker, + reduceOnly: false, + takeableOffers: 1, + collateralWithdrawals: 0, + collateralReceiver: zeroAddress, + referralFeePct: 0n, + referralFeeRecipient: zeroAddress, + maxContinuousFee: maxUint256, + deadline: maxUint256, + }); + expect(decoded.functionName).toBe( + "midnightBundlesV1BuyWithAssetsTargetAndWithdrawCollateral", + ); + expect(decoded.args[0]).toBe(1_000n); + expect(decoded.args[1]).toBe(900n); + expect(decoded.args?.[4]).toEqual({ + kind: PermitKind.None, + data: "0x", + }); + }); + + test("behavior: encodes loan token permit", () => { + const tx = midnightTakeLend({ + chainId: midnightChainId, + market: midnightMarket, + assets: 1_000n, + minUnits: 900n, + taker: midnightAddresses.taker, + takeableOffers: [midnightApiTake()], + deadline: maxUint256, + signatures: [ + { + action: { + type: "permit2Transfer", + args: { + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + deadline: 123n, + }, + }, + args: { + owner: midnightAddresses.taker, + nonce: 42n, + asset: midnightAddresses.loanToken, + signature: "0x1234", + amount: 1_000n, + deadline: 123n, + }, + } satisfies TokenRequirementSignature, + ], + }); + const decoded = decodeFunctionData({ + abi: midnightBundlesAbi, + data: tx.data, + }); + + expect(decoded.args?.[4]).toMatchObject({ + kind: PermitKind.Permit2, + }); + }); + + test("error: EmptyMidnightTakeableOffersError", () => { + expect(() => + midnightTakeLend({ + chainId: midnightChainId, + market: midnightMarket, + assets: 1_000n, + minUnits: 900n, + taker: midnightAddresses.taker, + takeableOffers: [], + deadline: maxUint256, + }), + ).toThrow(EmptyMidnightTakeableOffersError); + }); + + test("error: MidnightOfferSideMismatchError", () => { + const takeableOffers = [midnightApiTake({ buy: true })]; + + expect(() => + midnightTakeLend({ + chainId: midnightChainId, + market: midnightMarket, + assets: 1_000n, + minUnits: 900n, + taker: midnightAddresses.taker, + takeableOffers, + deadline: maxUint256, + }), + ).toThrow(MidnightOfferSideMismatchError); + }); + + test("error: MidnightTakeableOfferMarketMismatchError", () => { + const takeableOffers = [midnightApiTake({ market: midnightOtherMarket })]; + + expect(() => + midnightTakeLend({ + chainId: midnightChainId, + market: midnightMarket, + assets: 1_000n, + minUnits: 900n, + taker: midnightAddresses.taker, + takeableOffers, + deadline: maxUint256, + }), + ).toThrow(MidnightTakeableOfferMarketMismatchError); + }); +}); diff --git a/packages/morpho-sdk/src/actions/midnight/takeLend.ts b/packages/morpho-sdk/src/actions/midnight/takeLend.ts new file mode 100644 index 000000000..db819e8d0 --- /dev/null +++ b/packages/morpho-sdk/src/actions/midnight/takeLend.ts @@ -0,0 +1,174 @@ +import { + type MarketInput, + MarketUtils, + midnightBundlesAbi, +} from "@morpho-org/midnight-sdk"; +import { deepFreeze, getChainAddress } from "@morpho-org/morpho-ts"; +import { + type Address, + encodeFunctionData, + maxUint256, + zeroAddress, +} from "viem"; +import { addTransactionMetadata } from "../../helpers/index.js"; +import { validateOfferSides } from "../../helpers/validateOfferSides.js"; +import { + type AnyRequirementSignature, + EmptyMidnightTakeableOffersError, + type Metadata, + MidnightTakeableOfferMarketMismatchError, + type MidnightTakeLendAction, + NegativeMidnightAmountError, + NonPositiveMidnightAmountError, + type Transaction, +} from "../../types/index.js"; +import { getMidnightTokenPermit } from "../signatures/getMidnightTokenPermit.js"; +import type { + MidnightCollateralWithdrawal, + MidnightTakeableOffer, +} from "./types.js"; + +/** Parameters for {@link midnightTakeLend}. */ +export interface MidnightTakeLendParams { + readonly chainId: number; + readonly market: MarketInput; + readonly assets: bigint; + readonly minUnits: bigint; + readonly taker: Address; + readonly reduceOnly?: boolean; + readonly takeableOffers: readonly MidnightTakeableOffer[]; + readonly collateralWithdrawals?: readonly MidnightCollateralWithdrawal[]; + readonly collateralReceiver?: Address; + readonly referralFeePct?: bigint; + readonly referralFeeRecipient?: Address; + readonly maxContinuousFee?: bigint; + /** Bundle execution deadline timestamp. Pass `maxUint256` explicitly for no expiry. */ + readonly deadline: bigint; + readonly signatures?: + | AnyRequirementSignature + | readonly AnyRequirementSignature[]; + readonly metadata?: Metadata; +} + +/** Encodes the take-lend Midnight bundle. */ +export const midnightTakeLend = ( + params: MidnightTakeLendParams, +): Readonly> => { + if (params.assets <= 0n) { + throw new NonPositiveMidnightAmountError("assets", params.assets); + } + if (params.minUnits < 0n) { + throw new NegativeMidnightAmountError("minUnits", params.minUnits); + } + if ((params.referralFeePct ?? 0n) < 0n) { + throw new NegativeMidnightAmountError( + "referralFeePct", + params.referralFeePct ?? 0n, + ); + } + if ((params.maxContinuousFee ?? maxUint256) < 0n) { + throw new NegativeMidnightAmountError( + "maxContinuousFee", + params.maxContinuousFee ?? maxUint256, + ); + } + if (params.deadline < 0n) { + throw new NegativeMidnightAmountError("deadline", params.deadline); + } + if (params.takeableOffers.length === 0) { + throw new EmptyMidnightTakeableOffersError(); + } + + const marketId = MarketUtils.toId(params.market); + validateOfferSides( + params.takeableOffers.map((take) => take.offer), + false, + ); + for (const [index, take] of params.takeableOffers.entries()) { + const actualMarketId = MarketUtils.toId(take.offer.market); + if (actualMarketId.toLowerCase() !== marketId.toLowerCase()) { + throw new MidnightTakeableOfferMarketMismatchError({ + index, + expectedMarket: marketId, + actualMarket: actualMarketId, + }); + } + } + + const midnightBundles = getChainAddress(params.chainId, "midnightBundles"); + const reduceOnly = params.reduceOnly ?? false; + const collateralWithdrawals = params.collateralWithdrawals ?? []; + for (const [index, withdrawal] of collateralWithdrawals.entries()) { + if (withdrawal.collateralIndex < 0n) { + throw new NegativeMidnightAmountError( + `collateralWithdrawals[${index}].collateralIndex`, + withdrawal.collateralIndex, + ); + } + if (withdrawal.assets < 0n) { + throw new NegativeMidnightAmountError( + `collateralWithdrawals[${index}].assets`, + withdrawal.assets, + ); + } + } + const collateralReceiver = params.collateralReceiver ?? zeroAddress; + const referralFeePct = params.referralFeePct ?? 0n; + const referralFeeRecipient = params.referralFeeRecipient ?? zeroAddress; + const maxContinuousFee = params.maxContinuousFee ?? maxUint256; + const loanTokenPermit = getMidnightTokenPermit({ + token: MarketUtils.toStruct(params.market).loanToken, + owner: params.taker, + spender: midnightBundles, + amount: params.assets, + signatures: params.signatures, + }); + + let tx = { + to: midnightBundles, + value: 0n, + data: encodeFunctionData({ + abi: midnightBundlesAbi, + functionName: "midnightBundlesV1BuyWithAssetsTargetAndWithdrawCollateral", + args: [ + params.assets, + params.minUnits, + params.taker, + reduceOnly, + loanTokenPermit, + params.takeableOffers, + collateralWithdrawals, + collateralReceiver, + referralFeePct, + referralFeeRecipient, + maxContinuousFee, + params.deadline, + ], + }), + }; + + if (params.metadata) { + tx = addTransactionMetadata(tx, params.metadata); + } + + return deepFreeze({ + ...tx, + action: { + type: "midnightTakeLend", + args: { + market: marketId, + assets: params.assets, + minUnits: params.minUnits, + taker: params.taker, + reduceOnly, + takeableOffers: params.takeableOffers.length, + collateralWithdrawals: collateralWithdrawals.length, + collateralReceiver, + referralFeePct, + referralFeeRecipient, + maxContinuousFee, + deadline: params.deadline, + }, + }, + }); +}; diff --git a/packages/morpho-sdk/src/actions/midnight/types.ts b/packages/morpho-sdk/src/actions/midnight/types.ts new file mode 100644 index 000000000..051da8f2f --- /dev/null +++ b/packages/morpho-sdk/src/actions/midnight/types.ts @@ -0,0 +1,47 @@ +import type { OfferStruct } from "@morpho-org/midnight-sdk"; +import type { Hex } from "viem"; + +/** + * Token permit mode accepted by Midnight Bundles when pulling loan or collateral tokens. + * + * Mirrors the Solidity `PermitKind` enum in `IMidnightBundles`. + */ +export enum PermitKind { + /** Use an existing ERC-20 allowance; `data` is ignored and should be empty bytes. */ + None = 0, + /** Use an ERC-2612 permit payload encoded as `(uint256 deadline, uint8 v, bytes32 r, bytes32 s)`. */ + ERC2612 = 1, + /** Use a Permit2 SignatureTransfer payload encoded as `(uint256 nonce, uint256 deadline, bytes signature)`. */ + Permit2 = 2, +} + +/** Token permit payload passed to Midnight Bundles token-pull helpers. */ +export type MidnightTokenPermit = + | { + readonly kind: PermitKind.None; + readonly data: "0x"; + } + | { + readonly kind: PermitKind.ERC2612 | PermitKind.Permit2; + readonly data: Hex; + }; + +/** Protocol-shaped collateral withdrawal used by Midnight bundle calls. */ +export interface MidnightCollateralWithdrawal { + readonly collateralIndex: bigint; + readonly assets: bigint; +} + +/** Protocol-shaped collateral supply used by Midnight bundle calls. */ +export interface MidnightCollateralSupply { + readonly collateralIndex: bigint; + readonly assets: bigint; + readonly permit: MidnightTokenPermit; +} + +/** ABI-ready Midnight takeable offer used by direct and bundled take flows. */ +export interface MidnightTakeableOffer { + readonly units: bigint; + readonly offer: OfferStruct; + readonly ratifierData: Hex; +} diff --git a/packages/morpho-sdk/src/actions/requirements/encode/encodeBlueSignatureAuthorization.ts b/packages/morpho-sdk/src/actions/requirements/encode/encodeBlueSignatureAuthorization.ts index a0ce26196..5e3d954cc 100644 --- a/packages/morpho-sdk/src/actions/requirements/encode/encodeBlueSignatureAuthorization.ts +++ b/packages/morpho-sdk/src/actions/requirements/encode/encodeBlueSignatureAuthorization.ts @@ -1,14 +1,12 @@ import type { Address } from "@morpho-org/blue-sdk"; import { getAuthorizationTypedData } from "@morpho-org/blue-sdk-viem"; import { deepFreeze, Time } from "@morpho-org/morpho-ts"; -import { type Client, verifyTypedData, type WalletClient } from "viem"; -import { signTypedData } from "viem/actions"; -import { validateUserAddress } from "../../../helpers/validate.js"; +import type { Client, WalletClient } from "viem"; +import { signAndVerifyTypedData } from "../../../helpers/signAndVerifyTypedData.js"; import { type AuthorizationAction, type AuthorizationRequirementSignature, ChainIdMismatchError, - InvalidSignatureError, type Requirement, } from "../../../types/index.js"; @@ -83,29 +81,16 @@ export const encodeBlueSignatureAuthorization = async ( return { action, async sign(client: WalletClient, userAddress: Address) { - const account = client.account; - validateUserAddress(account?.address, userAddress); - const typedData = getAuthorizationTypedData( { authorizer: userAddress, authorized, isAuthorized, nonce, deadline }, chainId, ); - - const signature = await signTypedData(client, { - ...typedData, - account, + const signature = await signAndVerifyTypedData({ + client, + userAddress, + typedData, }); - const isValid = await verifyTypedData({ - ...typedData, - address: userAddress, // Verify against the authorizer. - signature, - }); - - if (!isValid) { - throw new InvalidSignatureError(); - } - return deepFreeze({ args: { owner: userAddress, diff --git a/packages/morpho-sdk/src/actions/requirements/encode/encodeErc20Approval.ts b/packages/morpho-sdk/src/actions/requirements/encode/encodeErc20Approval.ts index 0aa332b61..7d6f81503 100644 --- a/packages/morpho-sdk/src/actions/requirements/encode/encodeErc20Approval.ts +++ b/packages/morpho-sdk/src/actions/requirements/encode/encodeErc20Approval.ts @@ -1,12 +1,9 @@ -import { type Address, getChainAddresses, MathLib } from "@morpho-org/blue-sdk"; +import { type Address, MathLib } from "@morpho-org/blue-sdk"; import { deepFreeze } from "@morpho-org/morpho-ts"; -import { encodeFunctionData, erc20Abi, isAddressEqual, maxUint256 } from "viem"; +import { encodeFunctionData, erc20Abi, maxUint256 } from "viem"; import { MAX_TOKEN_APPROVALS } from "../../../helpers/constant.js"; -import { - type ERC20ApprovalAction, - type Transaction, - UnsupportedErc20ApprovalSpenderError, -} from "../../../types/index.js"; +import { validateRequirementSpender } from "../../../helpers/validateRequirementSpender.js"; +import type { ERC20ApprovalAction, Transaction } from "../../../types/index.js"; /** Parameters for {@link encodeErc20Approval}. */ interface EncodeErc20ApprovalParams { @@ -17,18 +14,19 @@ interface EncodeErc20ApprovalParams { } /** - * Encodes a deep-frozen ERC-20 approval transaction for GeneralAdapter1 or Permit2. + * Encodes a deep-frozen ERC-20 approval transaction for a supported SDK spender. * * Caps `amount` at the per-chain, per-token maximum from `MAX_TOKEN_APPROVALS` (defaults to * `maxUint256`). Used by {@link getRequirementsApproval} and {@link getGeneralAdapterRequirementsPermit2}. * * @param params - Encoding parameters. * @param params.token - ERC-20 token address to approve. - * @param params.spender - Address granted the allowance. Must be GeneralAdapter1 or Permit2. + * @param params.spender - Address granted the allowance. Must be GeneralAdapter1, Permit2, + * Midnight, or MidnightBundles for the chain. * @param params.amount - Allowance amount before per-token cap. * @param params.chainId - The chain the transaction targets (used to resolve supported spenders and the per-token cap). * @returns A deep-frozen `Transaction` with the capped approval amount. - * @throws {UnsupportedErc20ApprovalSpenderError} when `spender` is not GeneralAdapter1 or Permit2 for `chainId`. + * @throws {UnsupportedErc20ApprovalSpenderError} when `spender` is not a supported SDK spender for `chainId`. * @example * ```ts * import { encodeErc20Approval } from "@morpho-org/morpho-sdk"; @@ -46,22 +44,11 @@ export const encodeErc20Approval = ( params: EncodeErc20ApprovalParams, ): Transaction => { const { token, spender, amount, chainId } = params; - const { - permit2, - bundler3: { generalAdapter1 }, - } = getChainAddresses(chainId); - - if ( - !isAddressEqual(spender, generalAdapter1) && - (permit2 == null || !isAddressEqual(spender, permit2)) - ) { - throw new UnsupportedErc20ApprovalSpenderError({ - spender, - chainId, - generalAdapter1, - permit2, - }); - } + validateRequirementSpender({ + chainId, + spender, + allowed: ["generalAdapter1", "permit2", "midnight", "midnightBundles"], + }); const amountValue = MathLib.min( amount, diff --git a/packages/morpho-sdk/src/actions/requirements/encode/encodeErc20Permit.ts b/packages/morpho-sdk/src/actions/requirements/encode/encodeErc20Permit.ts index 8088bb155..0226130c5 100644 --- a/packages/morpho-sdk/src/actions/requirements/encode/encodeErc20Permit.ts +++ b/packages/morpho-sdk/src/actions/requirements/encode/encodeErc20Permit.ts @@ -1,21 +1,14 @@ -import { type Address, getChainAddresses } from "@morpho-org/blue-sdk"; +import type { Address } from "@morpho-org/blue-sdk"; import { fetchToken, getPermitTypedData } from "@morpho-org/blue-sdk-viem"; import { deepFreeze, Time } from "@morpho-org/morpho-ts"; -import { - type Client, - isAddressEqual, - verifyTypedData, - type WalletClient, -} from "viem"; -import { signTypedData } from "viem/actions"; -import { validateUserAddress } from "../../../helpers/validate.js"; +import type { Client, WalletClient } from "viem"; +import { signAndVerifyTypedData } from "../../../helpers/signAndVerifyTypedData.js"; +import { validateRequirementSpender } from "../../../helpers/validateRequirementSpender.js"; import { ChainIdMismatchError, - InvalidSignatureError, type PermitAction, type PermitRequirementSignature, type Requirement, - UnsupportedErc20ApprovalSpenderError, } from "../../../types/index.js"; /** Parameters for {@link encodeErc20Permit}. */ @@ -77,23 +70,11 @@ export const encodeErc20Permit = async ( if (viemClient.chain?.id !== chainId) { throw new ChainIdMismatchError(viemClient.chain?.id, chainId); } - - const { - midnightBundles, - bundler3: { generalAdapter1 }, - } = getChainAddresses(chainId); - if ( - !isAddressEqual(spender, generalAdapter1) && - (midnightBundles == null || !isAddressEqual(spender, midnightBundles)) - ) { - throw new UnsupportedErc20ApprovalSpenderError({ - spender, - chainId, - generalAdapter1, - midnightBundles, - supportedSpenders: [generalAdapter1, midnightBundles], - }); - } + validateRequirementSpender({ + chainId, + spender, + allowed: ["generalAdapter1", "midnightBundles"], + }); const now = Time.timestamp(); const deadline = now + Time.s.from.h(2n); @@ -114,8 +95,6 @@ export const encodeErc20Permit = async ( return { action, async sign(client: WalletClient, userAddress: Address) { - const account = client.account; - validateUserAddress(account?.address, userAddress); const typedData = getPermitTypedData( { erc20: tokenData, @@ -127,22 +106,12 @@ export const encodeErc20Permit = async ( }, chainId, ); - - const signature = await signTypedData(client, { - ...typedData, - account, - }); - - const isValid = await verifyTypedData({ - ...typedData, - address: userAddress, // Verify against the permit's owner. - signature, + const signature = await signAndVerifyTypedData({ + client, + userAddress, + typedData, }); - if (!isValid) { - throw new InvalidSignatureError(); - } - return deepFreeze({ args: { owner: userAddress, diff --git a/packages/morpho-sdk/src/actions/requirements/encode/encodeErc20Permit2Approve.ts b/packages/morpho-sdk/src/actions/requirements/encode/encodeErc20Permit2Approve.ts index 34cef55b2..fcecd7328 100644 --- a/packages/morpho-sdk/src/actions/requirements/encode/encodeErc20Permit2Approve.ts +++ b/packages/morpho-sdk/src/actions/requirements/encode/encodeErc20Permit2Approve.ts @@ -1,14 +1,12 @@ import { type Address, getChainAddresses, MathLib } from "@morpho-org/blue-sdk"; import { getPermit2PermitTypedData } from "@morpho-org/blue-sdk-viem"; import { deepFreeze, Time } from "@morpho-org/morpho-ts"; -import { verifyTypedData, type WalletClient } from "viem"; -import { signTypedData } from "viem/actions"; -import { validateUserAddress } from "../../../helpers/validate.js"; -import { - InvalidSignatureError, - type Permit2Action, - type PermitRequirementSignature, - type Requirement, +import type { WalletClient } from "viem"; +import { signAndVerifyTypedData } from "../../../helpers/signAndVerifyTypedData.js"; +import type { + Permit2Action, + PermitRequirementSignature, + Requirement, } from "../../../types/index.js"; /** Parameters for {@link encodeErc20Permit2Approve}. */ @@ -80,9 +78,6 @@ export const encodeErc20Permit2Approve = ( return { action, async sign(client: WalletClient, userAddress: Address) { - const account = client.account; - validateUserAddress(account?.address, userAddress); - const typedData = getPermit2PermitTypedData( { spender: generalAdapter1, @@ -94,21 +89,12 @@ export const encodeErc20Permit2Approve = ( }, chainId, ); - const signature = await signTypedData(client, { - ...typedData, - account, - }); - - const isValid = await verifyTypedData({ - ...typedData, - address: userAddress, - signature, + const signature = await signAndVerifyTypedData({ + client, + userAddress, + typedData, }); - if (!isValid) { - throw new InvalidSignatureError(); - } - return deepFreeze({ args: { owner: userAddress, diff --git a/packages/morpho-sdk/src/actions/requirements/encode/encodeErc20Permit2Transfer.test.ts b/packages/morpho-sdk/src/actions/requirements/encode/encodeErc20Permit2Transfer.test.ts new file mode 100644 index 000000000..c8af63f20 --- /dev/null +++ b/packages/morpho-sdk/src/actions/requirements/encode/encodeErc20Permit2Transfer.test.ts @@ -0,0 +1,113 @@ +import { type Address, createWalletClient, custom, isHex } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { describe, expect, test } from "vitest"; +import { + midnightAddresses, + midnightChainId, +} from "../../../../test/fixtures/midnight.js"; +import { + AddressMismatchError, + InvalidSignatureError, + UnsupportedErc20ApprovalSpenderError, +} from "../../../types/index.js"; +import { encodeErc20Permit2Transfer } from "./encodeErc20Permit2Transfer.js"; + +describe("encodeErc20Permit2Transfer", () => { + const account = privateKeyToAccount( + "0x0000000000000000000000000000000000000000000000000000000000000001", + ); + const client = createWalletClient({ + account, + chain: { + id: midnightChainId, + name: "Midnight Test", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: { + default: { + http: ["http://127.0.0.1"], + }, + }, + }, + transport: custom({ + request: async () => { + throw new Error("Unexpected RPC request"); + }, + }), + }); + + test("default", async () => { + const requirement = encodeErc20Permit2Transfer({ + token: midnightAddresses.loanToken, + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + chainId: midnightChainId, + nonce: 42n, + }); + + const signature = await requirement.sign(client, client.account.address); + + expect(requirement.action.type).toBe("permit2Transfer"); + expect(requirement.action.args.spender).toBe( + midnightAddresses.midnightBundles, + ); + expect("expiration" in requirement.action.args).toBe(false); + expect(signature.args.owner).toBe(client.account.address); + expect(signature.args.asset).toBe(midnightAddresses.loanToken); + expect(signature.args.amount).toBe(1_000n); + expect(signature.args.nonce).toBe(42n); + expect("expiration" in signature.args).toBe(false); + expect(isHex(signature.args.signature)).toBe(true); + }); + + test("error: UnsupportedErc20ApprovalSpenderError", () => { + expect(() => + encodeErc20Permit2Transfer({ + token: midnightAddresses.loanToken, + spender: midnightAddresses.generalAdapter1, + amount: 1_000n, + chainId: midnightChainId, + nonce: 42n, + }), + ).toThrow(UnsupportedErc20ApprovalSpenderError); + }); + + test("error: AddressMismatchError", async () => { + const differentAddress = + "0x0000000000000000000000000000000000000001" as Address; + const requirement = encodeErc20Permit2Transfer({ + token: midnightAddresses.loanToken, + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + chainId: midnightChainId, + nonce: 42n, + }); + + await expect(requirement.sign(client, differentAddress)).rejects.toThrow( + new AddressMismatchError(client.account.address, differentAddress), + ); + }); + + test("error: InvalidSignatureError", async () => { + const wrongSigner = privateKeyToAccount( + "0x0000000000000000000000000000000000000000000000000000000000000002", + ); + const invalidSignatureClient = { + ...client, + account: { + ...wrongSigner, + address: client.account.address, + }, + }; + const requirement = encodeErc20Permit2Transfer({ + token: midnightAddresses.loanToken, + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + chainId: midnightChainId, + nonce: 42n, + }); + + await expect( + requirement.sign(invalidSignatureClient, client.account.address), + ).rejects.toThrow(InvalidSignatureError); + }); +}); diff --git a/packages/morpho-sdk/src/actions/requirements/encode/encodeErc20Permit2Transfer.ts b/packages/morpho-sdk/src/actions/requirements/encode/encodeErc20Permit2Transfer.ts new file mode 100644 index 000000000..eb54ef73c --- /dev/null +++ b/packages/morpho-sdk/src/actions/requirements/encode/encodeErc20Permit2Transfer.ts @@ -0,0 +1,104 @@ +import { getPermit2TransferFromTypedData } from "@morpho-org/blue-sdk-viem"; +import { deepFreeze, Time } from "@morpho-org/morpho-ts"; +import type { Address, WalletClient } from "viem"; +import { signAndVerifyTypedData } from "../../../helpers/signAndVerifyTypedData.js"; +import { validateRequirementSpender } from "../../../helpers/validateRequirementSpender.js"; +import type { + Permit2TransferAction, + Permit2TransferArgs, + Requirement, +} from "../../../types/index.js"; + +/** Parameters for {@link encodeErc20Permit2Transfer}. */ +export interface EncodeErc20Permit2TransferParams { + readonly token: Address; + readonly spender: Address; + readonly amount: bigint; + readonly chainId: number; + readonly nonce: bigint; +} + +/** + * Builds a Permit2 SignatureTransfer requirement for a supported SDK spender. + * + * Today only MidnightBundles consumes this signature shape. The spender remains explicit so the + * API can support future consumers without changing shape, but unsupported values are rejected + * before signing. + * + * @param params - Permit2 SignatureTransfer parameters. + * @param params.token - ERC20 token the spender will pull. + * @param params.spender - Address that will spend the Permit2 SignatureTransfer. Must be + * MidnightBundles for the chain. + * @param params.amount - Exact token amount the spender will pull. + * @param params.chainId - Chain id whose MidnightBundles and Permit2 deployments verify the signature. + * @param params.nonce - One-shot Permit2 unordered nonce. + * @returns A `permit2Transfer` requirement whose signature can be encoded into Midnight `TokenPermit`. + * @throws {UnsupportedErc20ApprovalSpenderError} when `spender` is not MidnightBundles for `chainId`. + * @throws {AddressMismatchError} from `sign()` when the client account differs from `userAddress`. + * @throws {MissingClientPropertyError} from `sign()` when the client has no account address. + * @throws {InvalidSignatureError} from `sign()` when EIP-712 verification fails. + * @example + * ```ts + * import { encodeErc20Permit2Transfer } from "@morpho-org/morpho-sdk"; + * + * const requirement = encodeErc20Permit2Transfer({ + * token: loanToken, + * spender: midnightBundles, + * amount: 1_000_000n, + * chainId: 1, + * nonce: 42n, + * }); + * ``` + */ +export const encodeErc20Permit2Transfer = ( + params: EncodeErc20Permit2TransferParams, +): Requirement => { + validateRequirementSpender({ + chainId: params.chainId, + spender: params.spender, + allowed: ["midnightBundles"], + }); + + const deadline = Time.timestamp() + Time.s.from.h(2n); + const action: Permit2TransferAction = { + type: "permit2Transfer", + args: { + spender: params.spender, + amount: params.amount, + deadline, + }, + }; + + return { + action, + async sign(client: WalletClient, userAddress: Address) { + const typedData = getPermit2TransferFromTypedData( + { + erc20: params.token, + allowance: params.amount, + spender: params.spender, + nonce: params.nonce, + deadline, + }, + params.chainId, + ); + const signature = await signAndVerifyTypedData({ + client, + userAddress, + typedData, + }); + + return deepFreeze({ + args: { + owner: userAddress, + nonce: params.nonce, + asset: params.token, + signature, + amount: params.amount, + deadline, + }, + action, + }); + }, + }; +}; diff --git a/packages/morpho-sdk/src/actions/requirements/encode/index.ts b/packages/morpho-sdk/src/actions/requirements/encode/index.ts index 8799d6ff2..709d90cb3 100644 --- a/packages/morpho-sdk/src/actions/requirements/encode/index.ts +++ b/packages/morpho-sdk/src/actions/requirements/encode/index.ts @@ -2,3 +2,4 @@ export * from "./encodeBlueSignatureAuthorization.js"; export * from "./encodeErc20Approval.js"; export * from "./encodeErc20Permit.js"; export * from "./encodeErc20Permit2Approve.js"; +export * from "./encodeErc20Permit2Transfer.js"; diff --git a/packages/morpho-sdk/src/actions/requirements/getRequirementsApproval.ts b/packages/morpho-sdk/src/actions/requirements/getRequirementsApproval.ts index 2a0d91775..98dc77b47 100644 --- a/packages/morpho-sdk/src/actions/requirements/getRequirementsApproval.ts +++ b/packages/morpho-sdk/src/actions/requirements/getRequirementsApproval.ts @@ -8,7 +8,11 @@ import { import { encodeErc20Approval } from "./encode/encodeErc20Approval.js"; /** - * Computes classic ERC-20 approval transactions for GeneralAdapter1 or Permit2, given the existing allowance. + * Computes classic ERC-20 approval transactions for a supported SDK spender, given the existing + * allowance. + * + * The spender is validated by {@link encodeErc20Approval}. Supported spenders are the chain's + * GeneralAdapter1, Permit2, Midnight, and MidnightBundles addresses when configured. * * Returns an empty array when the allowance already covers `spendAmount`. When the token is in * `APPROVE_ONLY_ONCE_TOKENS` (e.g. USDT) and the existing allowance is non-zero, prepends a @@ -16,16 +20,18 @@ import { encodeErc20Approval } from "./encode/encodeErc20Approval.js"; * before re-approving. * * @param params.address - ERC-20 token address. - * @param params.chainId - The chain the bundle targets. + * @param params.chainId - The chain the transaction targets, used to resolve supported spenders + * and token approval caps. * @param params.args.spendAmount - The amount the bundle will actually pull. * @param params.args.approvalAmount - The amount to approve (often equal to `spendAmount`, but * may be `MAX_UINT_160` for Permit2 prerequisites). - * @param params.args.spender - Address that will be granted the approval. Must be GeneralAdapter1 or Permit2. + * @param params.args.spender - Address that will be granted the approval. Must be GeneralAdapter1, + * Permit2, Midnight, or MidnightBundles for `chainId`. * @param params.allowances - The user's current allowance of `address` for `spender`. * @returns Up to two deep-frozen `Transaction` entries: an optional reset * followed by the new approval. Empty when no approval is needed. * @throws {ApprovalAmountLessThanSpendAmountError} when `approvalAmount < spendAmount`. - * @throws {UnsupportedErc20ApprovalSpenderError} when `spender` is not GeneralAdapter1 or Permit2 for `chainId`. + * @throws {UnsupportedErc20ApprovalSpenderError} when `spender` is not a supported SDK spender for `chainId`. * @example * ```ts * import { getRequirementsApproval } from "@morpho-org/morpho-sdk"; diff --git a/packages/morpho-sdk/src/actions/requirements/index.ts b/packages/morpho-sdk/src/actions/requirements/index.ts index 1f1657ca3..b5ffe53ab 100644 --- a/packages/morpho-sdk/src/actions/requirements/index.ts +++ b/packages/morpho-sdk/src/actions/requirements/index.ts @@ -2,3 +2,4 @@ export * from "./blue/index.js"; export * from "./encode/index.js"; export * from "./generalAdapter/index.js"; export * from "./getRequirementsApproval.js"; +export * from "./midnight/index.js"; diff --git a/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightApprovalRequirements.test.ts b/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightApprovalRequirements.test.ts new file mode 100644 index 000000000..6e8bbe93d --- /dev/null +++ b/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightApprovalRequirements.test.ts @@ -0,0 +1,80 @@ +import { createMockClient, mockRead } from "@morpho-org/test/mock"; +import type { Chain } from "viem"; +import { erc20Abi } from "viem"; +import { describe, expect, test } from "vitest"; +import { + midnightAddresses, + midnightChainId, +} from "../../../../test/fixtures/midnight.js"; +import { ChainIdMismatchError } from "../../../types/index.js"; +import { getMidnightApprovalRequirements } from "./getMidnightApprovalRequirements.js"; + +const midnightTestChain = { + id: midnightChainId, + name: "Midnight Test", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: { default: { http: ["http://localhost"] } }, +} as const satisfies Chain; + +const wrongChain = { + ...midnightTestChain, + id: midnightChainId + 1, +} as const satisfies Chain; + +describe("getMidnightApprovalRequirements", () => { + test("throws ChainIdMismatchError when the client chain differs", async () => { + const { client } = createMockClient(wrongChain); + + await expect( + getMidnightApprovalRequirements({ + viemClient: client, + chainId: midnightChainId, + token: midnightAddresses.loanToken, + owner: midnightAddresses.taker, + spender: midnightAddresses.midnightBundles, + amount: 1n, + }), + ).rejects.toThrow(ChainIdMismatchError); + }); + + test("returns no approval when amount is zero", async () => { + const { client } = createMockClient(midnightTestChain); + + await expect( + getMidnightApprovalRequirements({ + viemClient: client, + chainId: midnightChainId, + token: midnightAddresses.loanToken, + owner: midnightAddresses.taker, + spender: midnightAddresses.midnightBundles, + amount: 0n, + }), + ).resolves.toEqual([]); + }); + + test("returns an approval when allowance is insufficient", async () => { + const handle = createMockClient(midnightTestChain); + mockRead(handle, { + address: midnightAddresses.loanToken, + abi: erc20Abi, + functionName: "allowance", + result: 0n, + }); + + const requirements = await getMidnightApprovalRequirements({ + viemClient: handle.client, + chainId: midnightChainId, + token: midnightAddresses.loanToken, + owner: midnightAddresses.taker, + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + }); + + expect(requirements).toHaveLength(1); + expect(requirements[0]?.action.type).toBe("erc20Approval"); + expect(requirements[0]?.action.args.spender).toBe( + midnightAddresses.midnightBundles, + ); + expect(requirements[0]?.action.args.amount).toBe(1_000n); + }); +}); diff --git a/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightApprovalRequirements.ts b/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightApprovalRequirements.ts new file mode 100644 index 000000000..c1407e5c7 --- /dev/null +++ b/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightApprovalRequirements.ts @@ -0,0 +1,63 @@ +import type { Address, Client } from "viem"; +import { erc20Abi } from "viem"; +import { readContract } from "viem/actions"; +import { validateChainId } from "../../../helpers/index.js"; +import type { ERC20ApprovalAction, Transaction } from "../../../types/index.js"; +import { getRequirementsApproval } from "../getRequirementsApproval.js"; + +/** Parameters for {@link getMidnightApprovalRequirements}. */ +export interface GetMidnightApprovalRequirementsParams { + readonly viemClient: Client; + readonly chainId: number; + readonly token: Address; + readonly owner: Address; + readonly spender: Address; + readonly amount: bigint; +} + +/** + * Resolves classic ERC20 approval requirements for a Midnight spender. + * + * @param params - Approval resolution parameters. + * @returns Approval transactions required for `spender` to pull `amount`. + * @throws {ChainIdMismatchError} when the viem client is connected to another chain. + * @example + * ```ts + * import { getMidnightApprovalRequirements } from "@morpho-org/morpho-sdk"; + * + * const approvals = await getMidnightApprovalRequirements({ + * viemClient: client, + * chainId: 8453, + * token: loanToken, + * owner: user, + * spender: midnightBundles, + * amount: 1_000_000n, + * }); + * console.log(approvals.length); + * ``` + */ +export const getMidnightApprovalRequirements = async ( + params: GetMidnightApprovalRequirementsParams, +): Promise>[]> => { + validateChainId(params.viemClient.chain?.id, params.chainId); + + if (params.amount === 0n) return []; + + const allowance = await readContract(params.viemClient, { + address: params.token, + abi: erc20Abi, + functionName: "allowance", + args: [params.owner, params.spender], + }); + + return getRequirementsApproval({ + address: params.token, + chainId: params.chainId, + args: { + spender: params.spender, + spendAmount: params.amount, + approvalAmount: params.amount, + }, + allowances: allowance, + }); +}; diff --git a/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightAuthorizationRequirement.test.ts b/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightAuthorizationRequirement.test.ts new file mode 100644 index 000000000..3814a33de --- /dev/null +++ b/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightAuthorizationRequirement.test.ts @@ -0,0 +1,77 @@ +import { midnightAbi } from "@morpho-org/midnight-sdk"; +import { createMockClient, mockRead } from "@morpho-org/test/mock"; +import type { Chain } from "viem"; +import { describe, expect, test } from "vitest"; +import { + midnightAddresses, + midnightChainId, +} from "../../../../test/fixtures/midnight.js"; +import { ChainIdMismatchError } from "../../../types/index.js"; +import { getMidnightAuthorizationRequirement } from "./getMidnightAuthorizationRequirement.js"; + +const midnightTestChain = { + id: midnightChainId, + name: "Midnight Test", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: { default: { http: ["http://localhost"] } }, +} as const satisfies Chain; + +const wrongChain = { + ...midnightTestChain, + id: midnightChainId + 1, +} as const satisfies Chain; + +describe("getMidnightAuthorizationRequirement", () => { + test("throws ChainIdMismatchError when the client chain differs", async () => { + const { client } = createMockClient(wrongChain); + + await expect( + getMidnightAuthorizationRequirement({ + viemClient: client, + chainId: midnightChainId, + owner: midnightAddresses.taker, + authorized: midnightAddresses.midnightBundles, + }), + ).rejects.toThrow(ChainIdMismatchError); + }); + + test("returns null when already authorized", async () => { + const handle = createMockClient(midnightTestChain); + mockRead(handle, { + address: midnightAddresses.midnight, + abi: midnightAbi, + functionName: "isAuthorized", + result: true, + }); + + await expect( + getMidnightAuthorizationRequirement({ + viemClient: handle.client, + chainId: midnightChainId, + owner: midnightAddresses.taker, + authorized: midnightAddresses.midnightBundles, + }), + ).resolves.toBeNull(); + }); + + test("builds an authorization transaction when authorization is missing", async () => { + const handle = createMockClient(midnightTestChain); + mockRead(handle, { + address: midnightAddresses.midnight, + abi: midnightAbi, + functionName: "isAuthorized", + result: false, + }); + + const tx = await getMidnightAuthorizationRequirement({ + viemClient: handle.client, + chainId: midnightChainId, + owner: midnightAddresses.taker, + authorized: midnightAddresses.midnightBundles, + }); + + expect(tx?.to).toBe(midnightAddresses.midnight); + expect(tx?.action.type).toBe("midnightAuthorization"); + expect(tx?.action.args.authorized).toBe(midnightAddresses.midnightBundles); + }); +}); diff --git a/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightAuthorizationRequirement.ts b/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightAuthorizationRequirement.ts new file mode 100644 index 000000000..eede0a9fb --- /dev/null +++ b/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightAuthorizationRequirement.ts @@ -0,0 +1,70 @@ +import { midnightAbi } from "@morpho-org/midnight-sdk"; +import { deepFreeze, getChainAddress } from "@morpho-org/morpho-ts"; +import { type Address, type Client, encodeFunctionData } from "viem"; +import { readContract } from "viem/actions"; +import { validateChainId } from "../../../helpers/index.js"; +import type { + MidnightAuthorizationAction, + Transaction, +} from "../../../types/index.js"; + +/** Parameters for {@link getMidnightAuthorizationRequirement}. */ +export interface GetMidnightAuthorizationRequirementParams { + readonly viemClient: Client; + readonly chainId: number; + readonly owner: Address; + readonly authorized: Address; +} + +/** + * Resolves the Midnight authorization transaction for a ratifier or bundle spender. + * + * @param params - Authorization resolution parameters. + * @returns Authorization transaction, or `null` when already authorized. + * @throws {ChainIdMismatchError} when the viem client is connected to another chain. + * @example + * ```ts + * import { getMidnightAuthorizationRequirement } from "@morpho-org/morpho-sdk"; + * + * const tx = await getMidnightAuthorizationRequirement({ + * viemClient: client, + * chainId: 8453, + * owner: user, + * authorized: midnightBundles, + * }); + * console.log(tx?.action.type); + * ``` + */ +export const getMidnightAuthorizationRequirement = async ( + params: GetMidnightAuthorizationRequirementParams, +): Promise> | null> => { + validateChainId(params.viemClient.chain?.id, params.chainId); + + const midnight = getChainAddress(params.chainId, "midnight"); + const isAuthorized = await readContract(params.viemClient, { + address: midnight, + abi: midnightAbi, + functionName: "isAuthorized", + args: [params.owner, params.authorized], + }); + + if (isAuthorized) return null; + + return deepFreeze({ + to: midnight, + value: 0n, + data: encodeFunctionData({ + abi: midnightAbi, + functionName: "setIsAuthorized", + args: [params.authorized, true, params.owner], + }), + action: { + type: "midnightAuthorization", + args: { + authorized: params.authorized, + isAuthorized: true, + onBehalf: params.owner, + }, + }, + }); +}; diff --git a/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightBundlesRequirements.test.ts b/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightBundlesRequirements.test.ts new file mode 100644 index 000000000..d254a862d --- /dev/null +++ b/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightBundlesRequirements.test.ts @@ -0,0 +1,463 @@ +import { MathLib } from "@morpho-org/blue-sdk"; +import { registerCustomAddresses } from "@morpho-org/morpho-ts"; +import { createMockClient, mockRead } from "@morpho-org/test/mock"; +import type { Chain } from "viem"; +import { + type Address, + decodeFunctionData, + encodeFunctionResult, + erc20Abi, + type Hex, + isAddressEqual, +} from "viem"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + midnightAddresses, + midnightChainId, +} from "../../../../test/fixtures/midnight.js"; +import { + ChainIdMismatchError, + CryptoUnavailableError, + isRequirementApproval, + isRequirementSignature, +} from "../../../types/index.js"; +import { getMidnightBundlesRequirements } from "./getMidnightBundlesRequirements.js"; +import { getMidnightBundlesRequirementsPermit } from "./getMidnightBundlesRequirementsPermit.js"; +import { getMidnightBundlesRequirementsPermit2 } from "./getMidnightBundlesRequirementsPermit2.js"; + +vi.mock("@morpho-org/blue-sdk-viem", async (importOriginal) => { + const actual = + await importOriginal(); + + return { + ...actual, + fetchToken: vi.fn(), + }; +}); + +import { erc2612Abi, fetchToken } from "@morpho-org/blue-sdk-viem"; + +const midnightTestChain = { + id: midnightChainId, + name: "Midnight Test", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: { default: { http: ["http://localhost"] } }, +} as const satisfies Chain; + +const wrongChain = { + ...midnightTestChain, + id: midnightChainId + 1, +} as const satisfies Chain; + +const noPermit2ChainId = midnightChainId + 2; +const noPermit2Chain = { + ...midnightTestChain, + id: noPermit2ChainId, + name: "Midnight Test Without Permit2", +} as const satisfies Chain; + +registerCustomAddresses({ + addresses: { + [noPermit2ChainId]: { + morpho: midnightAddresses.morpho, + bundler3: { + bundler3: midnightAddresses.bundler3, + generalAdapter1: midnightAddresses.generalAdapter1, + }, + adaptiveCurveIrm: midnightAddresses.adaptiveCurveIrm, + midnight: midnightAddresses.midnight, + midnightBundles: midnightAddresses.midnightBundles, + midnightMempool: midnightAddresses.midnightMempool, + ecrecoverRatifier: midnightAddresses.ecrecoverRatifier, + setterRatifier: midnightAddresses.setterRatifier, + }, + }, +}); + +const mockAllowanceReads = (params: { + readonly handle: ReturnType; + readonly chainId?: number; + readonly token?: Address; + readonly directAllowance: bigint; + readonly permit2Allowance: bigint; + readonly nonce?: bigint; +}) => { + const token = params.token ?? midnightAddresses.loanToken; + let nonceReads = 0; + + params.handle.request.mockImplementation(async ({ method, params: rpc }) => { + if (method === "eth_chainId") { + return `0x${(params.chainId ?? midnightChainId).toString(16)}`; + } + if (method === "eth_call") { + const [tx] = (rpc ?? []) as [{ to?: Address; data?: Hex }]; + if (tx?.to != null && isAddressEqual(tx.to, token) && tx.data != null) { + try { + const decodedNonce = decodeFunctionData({ + abi: erc2612Abi, + data: tx.data, + }); + if (decodedNonce.functionName === "nonces" && params.nonce != null) { + nonceReads += 1; + + return encodeFunctionResult({ + abi: erc2612Abi, + functionName: "nonces", + result: params.nonce, + }); + } + } catch {} + + const decoded = decodeFunctionData({ + abi: erc20Abi, + data: tx.data, + }); + if (decoded.functionName === "allowance") { + const spender = decoded.args[1]; + const result = isAddressEqual( + spender, + midnightAddresses.midnightBundles, + ) + ? params.directAllowance + : params.permit2Allowance; + + return encodeFunctionResult({ + abi: erc20Abi, + functionName: "allowance", + result, + }); + } + } + } + + throw new Error(`unhandled RPC ${method}`); + }); + + return { + get nonceReads() { + return nonceReads; + }, + }; +}; + +describe.sequential("getMidnightBundlesRequirements", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchToken).mockResolvedValue({ + address: midnightAddresses.loanToken, + decimals: 6, + symbol: "MOCK", + name: "Mock Token", + fromUsd: () => 0n, + toUsd: () => 0n, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("error: ChainIdMismatchError", async () => { + const { client } = createMockClient(wrongChain); + + await expect( + getMidnightBundlesRequirements({ + viemClient: client, + chainId: midnightChainId, + token: midnightAddresses.loanToken, + owner: midnightAddresses.taker, + amount: 1n, + supportSignature: false, + }), + ).rejects.toThrow(ChainIdMismatchError); + }); + + test("default", async () => { + const handle = createMockClient(midnightTestChain); + mockRead(handle, { + address: midnightAddresses.loanToken, + abi: erc20Abi, + functionName: "allowance", + result: 2_000n, + }); + + const requirements = await getMidnightBundlesRequirements({ + viemClient: handle.client, + chainId: midnightChainId, + token: midnightAddresses.loanToken, + owner: midnightAddresses.taker, + amount: 1_000n, + supportSignature: false, + }); + + expect(requirements).toEqual([]); + }); + + test("behavior: returns no requirements when amount is zero", async () => { + const handle = createMockClient(midnightTestChain); + + const requirements = await getMidnightBundlesRequirements({ + viemClient: handle.client, + chainId: midnightChainId, + token: midnightAddresses.loanToken, + owner: midnightAddresses.taker, + amount: 0n, + supportSignature: true, + }); + + expect(requirements).toEqual([]); + expect(handle.request).not.toHaveBeenCalled(); + }); + + test("behavior: returns classic approval when signatures are disabled", async () => { + const handle = createMockClient(midnightTestChain); + mockRead(handle, { + address: midnightAddresses.loanToken, + abi: erc20Abi, + functionName: "allowance", + result: 0n, + }); + + const requirements = await getMidnightBundlesRequirements({ + viemClient: handle.client, + chainId: midnightChainId, + token: midnightAddresses.loanToken, + owner: midnightAddresses.taker, + amount: 1_000n, + supportSignature: false, + }); + + expect(requirements).toHaveLength(1); + const approval = requirements[0]; + if (!isRequirementApproval(approval)) { + throw new Error("Requirement is not an approval transaction"); + } + expect(approval.action.args.spender).toBe( + midnightAddresses.midnightBundles, + ); + expect(approval.action.args.amount).toBe(1_000n); + }); + + test("behavior: returns ERC2612 permit when requested and supported", async () => { + const handle = createMockClient(midnightTestChain); + mockRead(handle, { + address: midnightAddresses.loanToken, + abi: erc20Abi, + functionName: "allowance", + result: 0n, + }); + mockRead(handle, { + address: midnightAddresses.loanToken, + abi: erc2612Abi, + functionName: "nonces", + result: 7n, + }); + + const requirements = await getMidnightBundlesRequirements({ + viemClient: handle.client, + chainId: midnightChainId, + token: midnightAddresses.loanToken, + owner: midnightAddresses.taker, + amount: 1_000n, + supportSignature: true, + useSimplePermit: true, + }); + + expect(requirements).toHaveLength(1); + const permit = requirements[0]; + if (!isRequirementSignature(permit)) { + throw new Error("Requirement is not a signature requirement"); + } + if (permit.action.type !== "permit") { + throw new Error("Requirement is not an ERC2612 permit"); + } + expect(permit.action.args.spender).toBe(midnightAddresses.midnightBundles); + }); + + test("getMidnightBundlesRequirementsPermit returns an exact permit requirement", async () => { + const { client } = createMockClient(midnightTestChain); + + const requirements = await getMidnightBundlesRequirementsPermit(client, { + token: midnightAddresses.loanToken, + spender: midnightAddresses.midnightBundles, + chainId: midnightChainId, + args: { amount: 1_000n }, + nonce: 7n, + }); + + expect(requirements).toHaveLength(1); + expect(requirements[0]?.action.type).toBe("permit"); + expect(requirements[0]?.action.args.spender).toBe( + midnightAddresses.midnightBundles, + ); + expect(requirements[0]?.action.args.amount).toBe(1_000n); + }); + + test("behavior: returns Permit2 signature with optional approval", async () => { + const handle = createMockClient(midnightTestChain); + mockAllowanceReads({ + handle, + directAllowance: 0n, + permit2Allowance: 0n, + }); + + const requirements = await getMidnightBundlesRequirements({ + viemClient: handle.client, + chainId: midnightChainId, + token: midnightAddresses.loanToken, + owner: midnightAddresses.taker, + amount: 1_000n, + supportSignature: true, + }); + + expect(requirements).toHaveLength(2); + const approval = requirements[0]; + const permit2 = requirements[1]; + if (!isRequirementApproval(approval)) { + throw new Error("Requirement is not an approval transaction"); + } + if (!isRequirementSignature(permit2)) { + throw new Error("Requirement is not a signature requirement"); + } + expect(approval.action.args.spender).toBe(midnightAddresses.permit2); + expect(approval.action.args.amount).toBe(MathLib.MAX_UINT_160); + if (permit2.action.type !== "permit2Transfer") { + throw new Error("Requirement is not a Permit2 transfer signature"); + } + expect(permit2.action.args.spender).toBe(midnightAddresses.midnightBundles); + }); + + test("getMidnightBundlesRequirementsPermit2 returns Permit2 approval and transfer signature", () => { + const requirements = getMidnightBundlesRequirementsPermit2({ + address: midnightAddresses.loanToken, + chainId: midnightChainId, + permit2: midnightAddresses.permit2, + spender: midnightAddresses.midnightBundles, + args: { amount: 1_000n }, + erc20Allowances: { permit2: 0n }, + nonce: 42n, + }); + + expect(requirements).toHaveLength(2); + const approval = requirements[0]; + const permit2 = requirements[1]; + if (!isRequirementApproval(approval)) { + throw new Error("Requirement is not an approval transaction"); + } + if (!isRequirementSignature(permit2)) { + throw new Error("Requirement is not a signature requirement"); + } + expect(approval.action.args.spender).toBe(midnightAddresses.permit2); + expect(approval.action.args.amount).toBe(MathLib.MAX_UINT_160); + expect(permit2.action.type).toBe("permit2Transfer"); + expect(permit2.action.args.spender).toBe(midnightAddresses.midnightBundles); + expect(permit2.action.args.amount).toBe(1_000n); + }); + + test("behavior: returns classic approval when Permit2 is not deployed", async () => { + const handle = createMockClient(noPermit2Chain); + mockAllowanceReads({ + handle, + chainId: noPermit2ChainId, + directAllowance: 0n, + permit2Allowance: 0n, + }); + + const requirements = await getMidnightBundlesRequirements({ + viemClient: handle.client, + chainId: noPermit2ChainId, + token: midnightAddresses.loanToken, + owner: midnightAddresses.taker, + amount: 1_000n, + supportSignature: true, + }); + + expect(requirements).toHaveLength(1); + const approval = requirements[0]; + expect(isRequirementApproval(approval)).toBe(true); + if (isRequirementApproval(approval)) { + expect(approval.action.args.spender).toBe( + midnightAddresses.midnightBundles, + ); + expect(approval.action.args.amount).toBe(1_000n); + } + }); + + test("behavior: skips ERC2612 simple permit for DAI", async () => { + const handle = createMockClient(midnightTestChain); + const reads = mockAllowanceReads({ + handle, + token: midnightAddresses.dai, + directAllowance: 0n, + permit2Allowance: 0n, + nonce: 7n, + }); + const cryptoDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + "crypto", + ); + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { + getRandomValues: (bytes: Uint8Array) => bytes.fill(1), + }, + }); + + try { + const requirements = await getMidnightBundlesRequirements({ + viemClient: handle.client, + chainId: midnightChainId, + token: midnightAddresses.dai, + owner: midnightAddresses.taker, + amount: 1_000n, + supportSignature: true, + useSimplePermit: true, + }); + + expect(reads.nonceReads).toBe(0); + expect(requirements).toHaveLength(2); + const approval = requirements[0]; + const permit2 = requirements[1]; + if (!isRequirementApproval(approval)) { + throw new Error("Requirement is not an approval transaction"); + } + if (!isRequirementSignature(permit2)) { + throw new Error("Requirement is not a signature requirement"); + } + expect(approval.action.args.spender).toBe(midnightAddresses.permit2); + if (permit2.action.type !== "permit2Transfer") { + throw new Error("Requirement is not a Permit2 transfer signature"); + } + expect(permit2.action.args.spender).toBe( + midnightAddresses.midnightBundles, + ); + } finally { + if (cryptoDescriptor == null) { + Reflect.deleteProperty(globalThis, "crypto"); + } else { + Object.defineProperty(globalThis, "crypto", cryptoDescriptor); + } + } + }); + + test("error: CryptoUnavailableError", async () => { + const handle = createMockClient(midnightTestChain); + mockAllowanceReads({ + handle, + directAllowance: 0n, + permit2Allowance: 0n, + }); + vi.stubGlobal("crypto", undefined); + + await expect( + getMidnightBundlesRequirements({ + viemClient: handle.client, + chainId: midnightChainId, + token: midnightAddresses.loanToken, + owner: midnightAddresses.taker, + amount: 1_000n, + supportSignature: true, + }), + ).rejects.toThrow(CryptoUnavailableError); + }); +}); diff --git a/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightBundlesRequirements.ts b/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightBundlesRequirements.ts new file mode 100644 index 000000000..5e6861d24 --- /dev/null +++ b/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightBundlesRequirements.ts @@ -0,0 +1,172 @@ +import { erc2612Abi } from "@morpho-org/blue-sdk-viem"; +import { getChainAddress, getChainAddresses } from "@morpho-org/morpho-ts"; +import { + type Address, + bytesToHex, + type Client, + erc20Abi, + hexToBigInt, + isAddressEqual, +} from "viem"; +import { readContract } from "viem/actions"; +import { validateChainId } from "../../../helpers/index.js"; +import { + type ActionRequirement, + CryptoUnavailableError, +} from "../../../types/index.js"; +import { getRequirementsApproval } from "../getRequirementsApproval.js"; +import { getMidnightBundlesRequirementsPermit } from "./getMidnightBundlesRequirementsPermit.js"; +import { getMidnightBundlesRequirementsPermit2 } from "./getMidnightBundlesRequirementsPermit2.js"; + +/** Parameters for {@link getMidnightBundlesRequirements}. */ +export type GetMidnightBundlesRequirementsParams = + | { + readonly viemClient: Client; + readonly chainId: number; + readonly token: Address; + readonly owner: Address; + readonly amount: bigint; + readonly supportDeployless?: boolean; + readonly supportSignature: false; + } + | { + readonly viemClient: Client; + readonly chainId: number; + readonly token: Address; + readonly owner: Address; + readonly amount: bigint; + readonly supportDeployless?: boolean; + readonly supportSignature: true; + /** + * Prefer the ERC-2612 simple-permit path when the SDK detects support. + * Leave unset or set to `false` to force the Permit2 fallback when a token is known to be + * incompatible despite passing the SDK's shallow `nonces(owner)` probe. + */ + readonly useSimplePermit?: boolean; + }; + +/** + * Resolves token-pull prerequisites for Midnight bundle calls. + * + * Resolves the MidnightBundles spender from `chainId`, reads the user's direct ERC-20 allowance + * to that contract, then picks one of three flows: + * + * 1. **`supportSignature: false`** - classic ERC-20 `approve` transaction to the Midnight bundle. + * 2. **`supportSignature: true` + EIP-2612 nonce detected + `useSimplePermit`** - single permit + * signature against the token itself, except for DAI's non-standard permit shape. + * 3. **`supportSignature: true`, default** - Permit2 SignatureTransfer: optional ERC-20 approval + * to Permit2, followed by a one-shot Permit2 signature scoped to the Midnight bundle spender. + * + * The simple-permit compatibility check is intentionally shallow: the SDK only verifies that + * `nonces(owner)` is readable, and excludes DAI. Leaving `useSimplePermit` unset, or passing + * `false`, is the caller escape hatch for tokens that expose `nonces` but are still incompatible + * with the SDK's ERC-2612 encoder. This opt-out has proven useful in the past, but the SDK does + * not encode a token-specific example here. DAI is handled as a built-in version of that + * incompatibility: it exposes `nonces(owner)` but is always routed to Permit2 SignatureTransfer + * or classic approval instead of DAI-specific permit signing. + * + * @param params - Requirement resolution parameters. + * @param params.useSimplePermit - When `supportSignature` is `true`, prefer EIP-2612 permit if + * the `nonces(owner)` probe detects support. Leave unset or pass `false` to force the Permit2 + * fallback for tokens known to be incompatible despite passing that probe. + * @returns Ordered approval transactions and/or signature requirements for the bundle token pull. + * @throws {ChainIdMismatchError} when the viem client is connected to another chain. + * @throws {CryptoUnavailableError} when the runtime crypto API is unavailable for Permit2 nonce generation. + * @example + * ```ts + * import { getMidnightBundlesRequirements } from "@morpho-org/morpho-sdk"; + * + * const requirements = await getMidnightBundlesRequirements({ + * viemClient: client, + * chainId: 1, + * token: loanToken, + * owner: user, + * amount: 1_000_000n, + * supportSignature: true, + * }); + * ``` + */ +export const getMidnightBundlesRequirements = async ( + params: GetMidnightBundlesRequirementsParams, +): Promise => { + validateChainId(params.viemClient.chain?.id, params.chainId); + + if (params.amount === 0n) return []; + + const midnightBundles = getChainAddress(params.chainId, "midnightBundles"); + const directAllowance = await readContract(params.viemClient, { + address: params.token, + abi: erc20Abi, + functionName: "allowance", + args: [params.owner, midnightBundles], + }); + + if (directAllowance >= params.amount) return []; + + if (params.supportSignature) { + const chainAddresses = getChainAddresses(params.chainId); + const supportSimplePermit = + params.useSimplePermit === true && + (chainAddresses.dai == null || + !isAddressEqual(params.token, chainAddresses.dai)); + + if (supportSimplePermit) { + const nonce = await readContract(params.viemClient, { + address: params.token, + abi: erc2612Abi, + functionName: "nonces", + args: [params.owner], + }).catch(() => undefined); + + if (nonce !== undefined) { + return getMidnightBundlesRequirementsPermit(params.viemClient, { + token: params.token, + spender: midnightBundles, + chainId: params.chainId, + args: { amount: params.amount }, + nonce, + supportDeployless: params.supportDeployless, + }); + } + } + + if (chainAddresses.permit2 != null) { + const permit2Allowance = await readContract(params.viemClient, { + address: params.token, + abi: erc20Abi, + functionName: "allowance", + args: [params.owner, chainAddresses.permit2], + }); + const nonceBytes = new Uint8Array(32); + if (globalThis.crypto?.getRandomValues == null) { + throw new CryptoUnavailableError("Permit2 unordered nonce generation"); + } + // Permit2 SignatureTransfer uses caller-chosen unordered nonces. The high-level helper + // generates one without a bitmap read, so it must be CSPRNG-backed to avoid collisions + // across outstanding signatures. + globalThis.crypto.getRandomValues(nonceBytes); + const nonce = hexToBigInt(bytesToHex(nonceBytes)); + + return getMidnightBundlesRequirementsPermit2({ + address: params.token, + chainId: params.chainId, + permit2: chainAddresses.permit2, + spender: midnightBundles, + args: { amount: params.amount }, + erc20Allowances: { permit2: permit2Allowance }, + nonce, + }); + } + } + + return getRequirementsApproval({ + address: params.token, + chainId: params.chainId, + args: { + spender: midnightBundles, + spendAmount: params.amount, + approvalAmount: params.amount, + }, + allowances: directAllowance, + }); +}; diff --git a/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightBundlesRequirementsPermit.ts b/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightBundlesRequirementsPermit.ts new file mode 100644 index 000000000..a1f5c8254 --- /dev/null +++ b/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightBundlesRequirementsPermit.ts @@ -0,0 +1,65 @@ +import type { Address, Client } from "viem"; +import type { + PermitRequirementSignature, + Requirement, +} from "../../../types/index.js"; +import { encodeErc20Permit } from "../encode/index.js"; + +/** + * Computes the EIP-2612 permit `Requirement` that lets MidnightBundles pull `amount` of `token`. + * + * @param viemClient - Connected viem `Client` (used by the returned `Requirement.sign()`). + * @param params.token - ERC-20 token address (must support EIP-2612). + * @param params.spender - MidnightBundles address that will spend the permit. + * @param params.chainId - The chain the bundle targets. + * @param params.args.amount - Required token amount. + * @param params.nonce - The user's current EIP-2612 nonce on `token`. + * @param params.supportDeployless - Whether to fetch token metadata via deployless multicall. + * @returns A single-element array containing the exact-amount `Requirement` to sign. + * @example + * ```ts + * import { getMidnightBundlesRequirementsPermit } from "@morpho-org/morpho-sdk"; + * + * const reqs = await getMidnightBundlesRequirementsPermit(client, { + * token: USDC, + * spender: midnightBundles, + * chainId: 1, + * args: { amount: 1_000_000n }, + * nonce: 0n, + * }); + * ``` + */ +export const getMidnightBundlesRequirementsPermit = async ( + viemClient: Client, + params: { + readonly token: Address; + readonly spender: Address; + readonly chainId: number; + readonly args: { readonly amount: bigint }; + readonly nonce: bigint; + readonly supportDeployless?: boolean; + }, +): Promise[]> => { + const { + token, + spender, + chainId, + args: { amount }, + nonce, + supportDeployless, + } = params; + + // Existing direct ERC-20 allowance is intentionally not an input here. ERC-2612 overwrites the + // allowance with the signed amount, and the bundle spends exactly `amount`, leaving no residual + // allowance after inclusion. + return [ + await encodeErc20Permit(viemClient, { + token, + spender, + amount, + chainId, + nonce, + supportDeployless, + }), + ]; +}; diff --git a/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightBundlesRequirementsPermit2.ts b/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightBundlesRequirementsPermit2.ts new file mode 100644 index 000000000..42afcdf45 --- /dev/null +++ b/packages/morpho-sdk/src/actions/requirements/midnight/getMidnightBundlesRequirementsPermit2.ts @@ -0,0 +1,90 @@ +import { type Address, MathLib } from "@morpho-org/blue-sdk"; +import type { + ERC20ApprovalAction, + Permit2TransferAction, + Permit2TransferArgs, + Requirement, + Transaction, +} from "../../../types/index.js"; +import { encodeErc20Permit2Transfer } from "../encode/index.js"; +import { getRequirementsApproval } from "../getRequirementsApproval.js"; + +/** + * Computes the Permit2 prerequisites for MidnightBundles to pull `amount` of `address`. + * + * Emits two ordered prerequisites: + * + * 1. A classic ERC-20 approval to the Permit2 contract (infinite, if not already in place). + * 2. A Permit2 SignatureTransfer `Requirement` signed against MidnightBundles. + * + * @param params.address - ERC-20 token address. + * @param params.chainId - The chain the bundle targets. + * @param params.permit2 - The Permit2 contract address for the chain. + * @param params.spender - MidnightBundles address that will spend the SignatureTransfer. + * @param params.args.amount - Required token amount. + * @param params.erc20Allowances - Current ERC-20 allowances keyed by spender contract name. + * @param params.nonce - One-shot Permit2 unordered nonce. + * @returns Ordered list of approval transactions and/or `Requirement` objects to satisfy before bundling. + * @throws {ApprovalAmountLessThanSpendAmountError} from the inner approval helper when its + * bookkeeping invariants break (should not happen with the values this function passes). + * @throws {UnsupportedErc20ApprovalSpenderError} when `spender` is not MidnightBundles for `chainId`. + * @example + * ```ts + * import { getChainAddresses } from "@morpho-org/blue-sdk"; + * import { getMidnightBundlesRequirementsPermit2 } from "@morpho-org/morpho-sdk"; + * + * const { permit2, midnightBundles } = getChainAddresses(1); + * if (!permit2 || !midnightBundles) throw new Error("Midnight bundles not configured"); + * const requirements = getMidnightBundlesRequirementsPermit2({ + * address: USDC, + * chainId: 1, + * permit2, + * spender: midnightBundles, + * args: { amount: 1_000_000n }, + * erc20Allowances: { permit2: 0n }, + * nonce: 42n, + * }); + * ``` + */ +export const getMidnightBundlesRequirementsPermit2 = (params: { + readonly address: Address; + readonly chainId: number; + readonly permit2: Address; + readonly spender: Address; + readonly args: { readonly amount: bigint }; + readonly erc20Allowances: { readonly permit2: bigint }; + readonly nonce: bigint; +}): readonly ( + | Transaction + | Requirement +)[] => { + const { + address, + chainId, + permit2, + spender, + args: { amount }, + erc20Allowances, + nonce, + } = params; + + return [ + ...getRequirementsApproval({ + address, + chainId, + args: { + approvalAmount: MathLib.MAX_UINT_160, + spendAmount: amount, + spender: permit2, + }, + allowances: erc20Allowances.permit2, + }), + encodeErc20Permit2Transfer({ + token: address, + spender, + amount, + chainId, + nonce, + }), + ]; +}; diff --git a/packages/morpho-sdk/src/actions/requirements/midnight/getSetterRatifierRatifyRootRequirement.test.ts b/packages/morpho-sdk/src/actions/requirements/midnight/getSetterRatifierRatifyRootRequirement.test.ts new file mode 100644 index 000000000..19581a542 --- /dev/null +++ b/packages/morpho-sdk/src/actions/requirements/midnight/getSetterRatifierRatifyRootRequirement.test.ts @@ -0,0 +1,81 @@ +import { setterRatifierAbi } from "@morpho-org/midnight-sdk"; +import { createMockClient, mockRead } from "@morpho-org/test/mock"; +import type { Chain, Hex } from "viem"; +import { describe, expect, test } from "vitest"; +import { + midnightAddresses, + midnightChainId, +} from "../../../../test/fixtures/midnight.js"; +import { ChainIdMismatchError } from "../../../types/index.js"; +import { getSetterRatifierRatifyRootRequirement } from "./getSetterRatifierRatifyRootRequirement.js"; + +const midnightTestChain = { + id: midnightChainId, + name: "Midnight Test", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: { default: { http: ["http://localhost"] } }, +} as const satisfies Chain; + +const wrongChain = { + ...midnightTestChain, + id: midnightChainId + 1, +} as const satisfies Chain; + +const root = + "0x1111111111111111111111111111111111111111111111111111111111111111" as Hex; + +describe("getSetterRatifierRatifyRootRequirement", () => { + test("throws ChainIdMismatchError when the client chain differs", async () => { + const { client } = createMockClient(wrongChain); + + await expect( + getSetterRatifierRatifyRootRequirement({ + viemClient: client, + chainId: midnightChainId, + maker: midnightAddresses.maker, + root, + }), + ).rejects.toThrow(ChainIdMismatchError); + }); + + test("returns null when the root is already ratified", async () => { + const handle = createMockClient(midnightTestChain); + mockRead(handle, { + address: midnightAddresses.setterRatifier, + abi: setterRatifierAbi, + functionName: "isRootRatified", + result: true, + }); + + await expect( + getSetterRatifierRatifyRootRequirement({ + viemClient: handle.client, + chainId: midnightChainId, + maker: midnightAddresses.maker, + root, + }), + ).resolves.toBeNull(); + }); + + test("builds a ratify-root transaction when the root is not ratified", async () => { + const handle = createMockClient(midnightTestChain); + mockRead(handle, { + address: midnightAddresses.setterRatifier, + abi: setterRatifierAbi, + functionName: "isRootRatified", + result: false, + }); + + const tx = await getSetterRatifierRatifyRootRequirement({ + viemClient: handle.client, + chainId: midnightChainId, + maker: midnightAddresses.maker, + root, + }); + + expect(tx?.to).toBe(midnightAddresses.setterRatifier); + expect(tx?.action.type).toBe("setterRatifierRatifyRoot"); + expect(tx?.action.args.maker).toBe(midnightAddresses.maker); + expect(tx?.action.args.root).toBe(root); + }); +}); diff --git a/packages/morpho-sdk/src/actions/requirements/midnight/getSetterRatifierRatifyRootRequirement.ts b/packages/morpho-sdk/src/actions/requirements/midnight/getSetterRatifierRatifyRootRequirement.ts new file mode 100644 index 000000000..774fac5ca --- /dev/null +++ b/packages/morpho-sdk/src/actions/requirements/midnight/getSetterRatifierRatifyRootRequirement.ts @@ -0,0 +1,70 @@ +import { setterRatifierAbi } from "@morpho-org/midnight-sdk"; +import { deepFreeze, getChainAddress } from "@morpho-org/morpho-ts"; +import { type Address, type Client, encodeFunctionData, type Hex } from "viem"; +import { readContract } from "viem/actions"; +import { validateChainId } from "../../../helpers/index.js"; +import type { + SetterRatifierRatifyRootAction, + Transaction, +} from "../../../types/index.js"; + +/** Parameters for {@link getSetterRatifierRatifyRootRequirement}. */ +export interface GetSetterRatifierRatifyRootRequirementParams { + readonly viemClient: Client; + readonly chainId: number; + readonly maker: Address; + readonly root: Hex; +} + +/** + * Resolves the SetterRatifier root approval transaction for a maker offer tree. + * + * @param params - Root approval resolution parameters. + * @returns Ratify-root transaction, or `null` when the root is already ratified. + * @throws {ChainIdMismatchError} when the viem client is connected to another chain. + * @example + * ```ts + * import { getSetterRatifierRatifyRootRequirement } from "@morpho-org/morpho-sdk"; + * + * const tx = await getSetterRatifierRatifyRootRequirement({ + * viemClient: client, + * chainId: 8453, + * maker: user, + * root, + * }); + * console.log(tx?.action.type); + * ``` + */ +export const getSetterRatifierRatifyRootRequirement = async ( + params: GetSetterRatifierRatifyRootRequirementParams, +): Promise> | null> => { + validateChainId(params.viemClient.chain?.id, params.chainId); + + const setterRatifier = getChainAddress(params.chainId, "setterRatifier"); + const isRootRatified = await readContract(params.viemClient, { + address: setterRatifier, + abi: setterRatifierAbi, + functionName: "isRootRatified", + args: [params.maker, params.root], + }); + + if (isRootRatified) return null; + + return deepFreeze({ + to: setterRatifier, + value: 0n, + data: encodeFunctionData({ + abi: setterRatifierAbi, + functionName: "setIsRootRatified", + args: [params.maker, params.root, true], + }), + action: { + type: "setterRatifierRatifyRoot", + args: { + maker: params.maker, + root: params.root, + isRootRatified: true, + }, + }, + }); +}; diff --git a/packages/morpho-sdk/src/actions/requirements/midnight/index.ts b/packages/morpho-sdk/src/actions/requirements/midnight/index.ts new file mode 100644 index 000000000..a531ac239 --- /dev/null +++ b/packages/morpho-sdk/src/actions/requirements/midnight/index.ts @@ -0,0 +1,6 @@ +export * from "./getMidnightApprovalRequirements.js"; +export * from "./getMidnightAuthorizationRequirement.js"; +export * from "./getMidnightBundlesRequirements.js"; +export * from "./getMidnightBundlesRequirementsPermit.js"; +export * from "./getMidnightBundlesRequirementsPermit2.js"; +export * from "./getSetterRatifierRatifyRootRequirement.js"; diff --git a/packages/morpho-sdk/src/actions/signatures/getMidnightTokenPermit.test.ts b/packages/morpho-sdk/src/actions/signatures/getMidnightTokenPermit.test.ts new file mode 100644 index 000000000..cc6722e46 --- /dev/null +++ b/packages/morpho-sdk/src/actions/signatures/getMidnightTokenPermit.test.ts @@ -0,0 +1,381 @@ +import { decodeAbiParameters, type Hex } from "viem"; +import { describe, expect, test } from "vitest"; +import { midnightAddresses } from "../../../test/fixtures/midnight.js"; +import { + AmbiguousRequirementSignaturesError, + DepositAmountMismatchError, + DepositAssetMismatchError, + DepositOwnerMismatchError, + DepositSpenderMismatchError, + MidnightPermit2TransferSignatureRequiredError, + type RequirementSignature, + type TokenRequirementSignature, + UnexpectedRequirementSignatureError, +} from "../../types/index.js"; +import { PermitKind } from "../midnight/types.js"; +import { getMidnightTokenPermit } from "./getMidnightTokenPermit.js"; + +const signature = `0x${"11".repeat(32)}${"22".repeat(32)}1b` as Hex; + +type HasExpiration = T extends { expiration: bigint } ? true : false; + +describe("getMidnightTokenPermit", () => { + test("default", () => { + expect( + getMidnightTokenPermit({ + token: midnightAddresses.loanToken, + owner: midnightAddresses.taker, + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + }), + ).toEqual({ kind: PermitKind.None, data: "0x" }); + }); + + test("behavior: narrows token signature args by action type", () => { + const assertTokenSignatureNarrowing = ( + collectedSignature: TokenRequirementSignature, + ) => { + switch (collectedSignature.action.type) { + case "permit": + { + const hasExpiration: HasExpiration = + false; + expect(hasExpiration).toBe(false); + } + break; + case "permit2": + { + const hasExpiration: HasExpiration = + true; + expect(hasExpiration).toBe(true); + } + break; + case "permit2Transfer": + { + const hasExpiration: HasExpiration = + false; + expect(hasExpiration).toBe(false); + } + break; + } + }; + + assertTokenSignatureNarrowing({ + action: { + type: "permit2Transfer", + args: { + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + deadline: 123n, + }, + }, + args: { + owner: midnightAddresses.taker, + nonce: 42n, + asset: midnightAddresses.loanToken, + signature, + amount: 1_000n, + deadline: 123n, + }, + }); + }); + + test("behavior: encodes ERC2612 signatures", () => { + const permitSignature = { + action: { + type: "permit", + args: { + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + deadline: 123n, + }, + }, + args: { + owner: midnightAddresses.taker, + nonce: 0n, + asset: midnightAddresses.loanToken, + signature, + amount: 1_000n, + deadline: 123n, + }, + } satisfies RequirementSignature; + + const permit = getMidnightTokenPermit({ + token: midnightAddresses.loanToken, + owner: midnightAddresses.taker, + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + signatures: [permitSignature], + }); + const decoded = decodeAbiParameters( + [ + { type: "uint256" }, + { type: "uint8" }, + { type: "bytes32" }, + { type: "bytes32" }, + ], + permit.data, + ); + + expect(permit.kind).toBe(PermitKind.ERC2612); + expect(decoded).toEqual([ + 123n, + 27, + `0x${"11".repeat(32)}`, + `0x${"22".repeat(32)}`, + ]); + }); + + test("behavior: encodes Permit2 transfer signatures", () => { + const permit2Signature = { + action: { + type: "permit2Transfer", + args: { + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + deadline: 123n, + }, + }, + args: { + owner: midnightAddresses.taker, + nonce: 42n, + asset: midnightAddresses.loanToken, + signature, + amount: 1_000n, + deadline: 123n, + }, + } satisfies TokenRequirementSignature; + + const permit = getMidnightTokenPermit({ + token: midnightAddresses.loanToken, + owner: midnightAddresses.taker, + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + signatures: [permit2Signature], + }); + const decoded = decodeAbiParameters( + [{ type: "uint256" }, { type: "uint256" }, { type: "bytes" }], + permit.data, + ); + + expect(permit.kind).toBe(PermitKind.Permit2); + expect(decoded).toEqual([42n, 123n, signature]); + }); + + test("error: MidnightPermit2TransferSignatureRequiredError", () => { + const permit2Signature = { + action: { + type: "permit2", + args: { + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + deadline: 123n, + expiration: 123n, + }, + }, + args: { + owner: midnightAddresses.taker, + nonce: 42n, + asset: midnightAddresses.loanToken, + signature, + amount: 1_000n, + deadline: 123n, + expiration: 123n, + }, + } satisfies RequirementSignature; + + expect(() => + getMidnightTokenPermit({ + token: midnightAddresses.loanToken, + owner: midnightAddresses.taker, + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + signatures: [permit2Signature], + }), + ).toThrow(MidnightPermit2TransferSignatureRequiredError); + }); + + test("error: AmbiguousRequirementSignaturesError", () => { + const permit2Signature = { + action: { + type: "permit2Transfer", + args: { + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + deadline: 123n, + }, + }, + args: { + owner: midnightAddresses.taker, + nonce: 42n, + asset: midnightAddresses.loanToken, + signature, + amount: 1_000n, + deadline: 123n, + }, + } satisfies TokenRequirementSignature; + + expect(() => + getMidnightTokenPermit({ + token: midnightAddresses.loanToken, + owner: midnightAddresses.taker, + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + signatures: [permit2Signature, permit2Signature], + }), + ).toThrow(AmbiguousRequirementSignaturesError); + }); + + test("error: UnexpectedRequirementSignatureError", () => { + const offerRootSignature = { + action: { + type: "midnightOfferRootSignature", + args: { + root: `0x${"33".repeat(32)}` as Hex, + ratifier: midnightAddresses.ecrecoverRatifier, + offers: 1, + }, + }, + args: { + owner: midnightAddresses.maker, + root: `0x${"33".repeat(32)}` as Hex, + signature, + payload: "0x1234", + }, + } satisfies RequirementSignature; + + expect(() => + getMidnightTokenPermit({ + token: midnightAddresses.loanToken, + owner: midnightAddresses.taker, + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + signatures: [offerRootSignature], + }), + ).toThrow(UnexpectedRequirementSignatureError); + }); + + test("error: DepositAssetMismatchError", () => { + const permitSignature = { + action: { + type: "permit", + args: { + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + deadline: 123n, + }, + }, + args: { + owner: midnightAddresses.taker, + nonce: 0n, + asset: midnightAddresses.collateralToken, + signature, + amount: 1_000n, + deadline: 123n, + }, + } satisfies RequirementSignature; + + expect(() => + getMidnightTokenPermit({ + token: midnightAddresses.loanToken, + owner: midnightAddresses.taker, + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + signatures: [permitSignature], + }), + ).toThrow(DepositAssetMismatchError); + }); + + test("error: DepositAmountMismatchError", () => { + const permitSignature = { + action: { + type: "permit", + args: { + spender: midnightAddresses.midnightBundles, + amount: 1_001n, + deadline: 123n, + }, + }, + args: { + owner: midnightAddresses.taker, + nonce: 0n, + asset: midnightAddresses.loanToken, + signature, + amount: 1_001n, + deadline: 123n, + }, + } satisfies RequirementSignature; + + expect(() => + getMidnightTokenPermit({ + token: midnightAddresses.loanToken, + owner: midnightAddresses.taker, + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + signatures: [permitSignature], + }), + ).toThrow(DepositAmountMismatchError); + }); + + test("error: DepositOwnerMismatchError", () => { + const permitSignature = { + action: { + type: "permit", + args: { + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + deadline: 123n, + }, + }, + args: { + owner: midnightAddresses.maker, + nonce: 0n, + asset: midnightAddresses.loanToken, + signature, + amount: 1_000n, + deadline: 123n, + }, + } satisfies RequirementSignature; + + expect(() => + getMidnightTokenPermit({ + token: midnightAddresses.loanToken, + owner: midnightAddresses.taker, + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + signatures: [permitSignature], + }), + ).toThrow(DepositOwnerMismatchError); + }); + + test("error: DepositSpenderMismatchError", () => { + const permitSignature = { + action: { + type: "permit", + args: { + spender: midnightAddresses.generalAdapter1, + amount: 1_000n, + deadline: 123n, + }, + }, + args: { + owner: midnightAddresses.taker, + nonce: 0n, + asset: midnightAddresses.loanToken, + signature, + amount: 1_000n, + deadline: 123n, + }, + } satisfies RequirementSignature; + + expect(() => + getMidnightTokenPermit({ + token: midnightAddresses.loanToken, + owner: midnightAddresses.taker, + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + signatures: [permitSignature], + }), + ).toThrow(DepositSpenderMismatchError); + }); +}); diff --git a/packages/morpho-sdk/src/actions/signatures/getMidnightTokenPermit.ts b/packages/morpho-sdk/src/actions/signatures/getMidnightTokenPermit.ts new file mode 100644 index 000000000..10d190694 --- /dev/null +++ b/packages/morpho-sdk/src/actions/signatures/getMidnightTokenPermit.ts @@ -0,0 +1,137 @@ +import { encodeAbiParameters, isAddressEqual, parseSignature } from "viem"; +import { + AmbiguousRequirementSignaturesError, + type AnyRequirementSignature, + DepositAmountMismatchError, + DepositAssetMismatchError, + DepositOwnerMismatchError, + DepositSpenderMismatchError, + MidnightPermit2TransferSignatureRequiredError, + selectRequirementSignatures, +} from "../../types/index.js"; +import { type MidnightTokenPermit, PermitKind } from "../midnight/types.js"; + +/** Parameters for {@link getMidnightTokenPermit}. */ +export interface GetMidnightTokenPermitParams { + readonly token: `0x${string}`; + readonly owner: `0x${string}`; + readonly spender: `0x${string}`; + readonly amount: bigint; + readonly signatures?: + | AnyRequirementSignature + | readonly AnyRequirementSignature[] + | undefined; +} + +/** + * Returns the Midnight bundle `TokenPermit` payload from a collected token signature. + * + * @param params - Token permit parameters. + * @param params.token - Token the bundle will pull. + * @param params.owner - Owner whose tokens the bundle will pull. + * @param params.spender - Midnight bundle address spending the signed permit. + * @param params.amount - Exact amount the bundle will pull. + * @param params.signatures - Optional collected requirement signatures. + * @returns Midnight bundle `TokenPermit` calldata payload. + * @throws {DepositAssetMismatchError} when a token signature targets another asset. + * @throws {DepositAmountMismatchError} when a token signature targets another amount. + * @throws {MidnightPermit2TransferSignatureRequiredError} when a Blue Permit2 allowance signature + * is passed instead of a Midnight Permit2 transfer signature. + * @example + * ```ts + * import { getMidnightTokenPermit } from "@morpho-org/morpho-sdk"; + * + * const permit = getMidnightTokenPermit({ + * token: loanToken, + * owner: taker, + * spender: midnightBundles, + * amount: 1_000_000n, + * signatures, + * }); + * ``` + */ +export const getMidnightTokenPermit = ( + params: GetMidnightTokenPermitParams, +): MidnightTokenPermit => { + const signatures = + params.signatures == null + ? [] + : Array.isArray(params.signatures) + ? params.signatures + : [params.signatures]; + const { permit, permit2Transfer } = selectRequirementSignatures(signatures, { + permit: true, + permit2Transfer: true, + }); + + if (permit?.action.type === "permit2") { + throw new MidnightPermit2TransferSignatureRequiredError(); + } + + if (permit != null && permit2Transfer != null) { + throw new AmbiguousRequirementSignaturesError("permit", 2); + } + + if (permit != null) { + if (!isAddressEqual(permit.args.asset, params.token)) { + throw new DepositAssetMismatchError(params.token, permit.args.asset); + } + + if (permit.args.amount !== params.amount) { + throw new DepositAmountMismatchError(params.amount, permit.args.amount); + } + if (!isAddressEqual(permit.args.owner, params.owner)) { + throw new DepositOwnerMismatchError(params.owner, permit.args.owner); + } + if (!isAddressEqual(permit.action.args.spender, params.spender)) { + throw new DepositSpenderMismatchError( + params.spender, + permit.action.args.spender, + ); + } + + const parsed = parseSignature(permit.args.signature); + const v = "v" in parsed ? Number(parsed.v) : parsed.yParity + 27; + + return { + kind: PermitKind.ERC2612, + data: encodeAbiParameters( + [ + { type: "uint256" }, + { type: "uint8" }, + { type: "bytes32" }, + { type: "bytes32" }, + ], + [permit.args.deadline, v, parsed.r, parsed.s], + ), + }; + } + + const transfer = permit2Transfer; + if (transfer == null) return { kind: PermitKind.None, data: "0x" }; + + if (!isAddressEqual(transfer.args.asset, params.token)) { + throw new DepositAssetMismatchError(params.token, transfer.args.asset); + } + + if (transfer.args.amount !== params.amount) { + throw new DepositAmountMismatchError(params.amount, transfer.args.amount); + } + if (!isAddressEqual(transfer.args.owner, params.owner)) { + throw new DepositOwnerMismatchError(params.owner, transfer.args.owner); + } + if (!isAddressEqual(transfer.action.args.spender, params.spender)) { + throw new DepositSpenderMismatchError( + params.spender, + transfer.action.args.spender, + ); + } + + return { + kind: PermitKind.Permit2, + data: encodeAbiParameters( + [{ type: "uint256" }, { type: "uint256" }, { type: "bytes" }], + [transfer.args.nonce, transfer.args.deadline, transfer.args.signature], + ), + }; +}; diff --git a/packages/morpho-sdk/src/actions/signatures/index.ts b/packages/morpho-sdk/src/actions/signatures/index.ts index 82c9c4370..6f2fd3795 100644 --- a/packages/morpho-sdk/src/actions/signatures/index.ts +++ b/packages/morpho-sdk/src/actions/signatures/index.ts @@ -1,2 +1,3 @@ export * from "./getBlueAuthorizationAction.js"; +export * from "./getMidnightTokenPermit.js"; export * from "./getTokenRequirementActions.js"; diff --git a/packages/morpho-sdk/src/client/morphoViemExtension.ts b/packages/morpho-sdk/src/client/morphoViemExtension.ts index a49a8655b..e540466d3 100644 --- a/packages/morpho-sdk/src/client/morphoViemExtension.ts +++ b/packages/morpho-sdk/src/client/morphoViemExtension.ts @@ -1,7 +1,12 @@ import { type MarketParams, MarketUtils } from "@morpho-org/blue-sdk"; import { deepFreeze } from "@morpho-org/morpho-ts"; import type { Address, Client } from "viem"; -import { MorphoBlue, MorphoVaultV1, MorphoVaultV2 } from "../entities/index.js"; +import { + MorphoBlue, + MorphoMidnight, + MorphoVaultV1, + MorphoVaultV2, +} from "../entities/index.js"; import { MarketIdMismatchError, type Metadata, @@ -51,6 +56,10 @@ function createMorphoNamespace( } return new MorphoBlue(namespace, marketParams, chainId); }, + + midnight(chainId: number) { + return new MorphoMidnight(namespace, chainId); + }, }; return namespace; diff --git a/packages/morpho-sdk/src/constants.ts b/packages/morpho-sdk/src/constants.ts index 002bf2f95..4351c39c6 100644 --- a/packages/morpho-sdk/src/constants.ts +++ b/packages/morpho-sdk/src/constants.ts @@ -8,6 +8,21 @@ export { SECONDS_PER_YEAR, TransactionType, } from "@morpho-org/blue-sdk"; +export { + CBP, + COLLATERAL_PARAMS_TYPEHASH, + DEFAULT_TICK_SPACING, + EIP712_DOMAIN_TYPEHASH, + MARKET_TYPEHASH, + MAX_COLLATERALS, + MAX_COLLATERALS_PER_BORROWER, + MAX_CONTINUOUS_FEE, + MAX_SETTLEMENT_FEES, + MAX_TICK, + OFFER_TYPEHASH, + PRICE_ROUNDING_STEP, + SETTLEMENT_FEE_BREAKPOINTS, +} from "@morpho-org/midnight-sdk"; export { BLUE_API_BASE_URL, BLUE_API_GRAPHQL_URL, diff --git a/packages/morpho-sdk/src/entities/AGENTS.md b/packages/morpho-sdk/src/entities/AGENTS.md index 65522be4a..363eb78d0 100644 --- a/packages/morpho-sdk/src/entities/AGENTS.md +++ b/packages/morpho-sdk/src/entities/AGENTS.md @@ -1,6 +1,6 @@ # `entities/` -`MorphoVaultV1` implements `VaultV1Actions`. `MorphoVaultV2` implements `VaultV2Actions`. `MorphoBlue` implements `BlueActions`. Inherits the rules in [`packages/morpho-sdk/AGENTS.md`](../../AGENTS.md). +`MorphoVaultV1` implements `VaultV1Actions`. `MorphoVaultV2` implements `VaultV2Actions`. `MorphoBlue` implements `BlueActions`. `MorphoMidnight` implements `MidnightActions`. Inherits the rules in [`packages/morpho-sdk/AGENTS.md`](../../AGENTS.md). ## Responsibilities diff --git a/packages/morpho-sdk/src/entities/index.ts b/packages/morpho-sdk/src/entities/index.ts index 392a7f3a2..2842fba0b 100644 --- a/packages/morpho-sdk/src/entities/index.ts +++ b/packages/morpho-sdk/src/entities/index.ts @@ -66,6 +66,7 @@ export { WrappedToken, } from "@morpho-org/blue-sdk"; export { MorphoBlue } from "./blue/index.js"; +export * from "./midnight/index.js"; export { type InputReallocationData, ReallocationData, diff --git a/packages/morpho-sdk/src/entities/midnight/index.ts b/packages/morpho-sdk/src/entities/midnight/index.ts new file mode 100644 index 000000000..1ad432548 --- /dev/null +++ b/packages/morpho-sdk/src/entities/midnight/index.ts @@ -0,0 +1,2 @@ +export * from "./midnight.js"; +export * from "./types.js"; diff --git a/packages/morpho-sdk/src/entities/midnight/midnight.test.ts b/packages/morpho-sdk/src/entities/midnight/midnight.test.ts new file mode 100644 index 000000000..f06dbb910 --- /dev/null +++ b/packages/morpho-sdk/src/entities/midnight/midnight.test.ts @@ -0,0 +1,988 @@ +import { + AccrualPosition, + Group, + Market, + MarketUtils, + midnightAbi, + Offer, + Tree, +} from "@morpho-org/midnight-sdk"; +import { createMockClient, mockRead } from "@morpho-org/test/mock"; +import { + type Address, + type Chain, + createWalletClient, + custom, + erc20Abi, + type Hex, + maxUint256, + numberToHex, + zeroAddress, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { describe, expect, test } from "vitest"; +import { + midnightAddresses, + midnightApiTake, + midnightBaseOffer, + midnightChainId, + midnightMarket, + midnightMarketId, + midnightOtherMarket, +} from "../../../test/fixtures/midnight.js"; +import type { + MempoolSubmitOffersAction, + MidnightOfferRootSignature, + TokenRequirementSignature, + Transaction, +} from "../../types/action.js"; +import type { MorphoClientType } from "../../types/client.js"; +import { + AmbiguousRequirementSignaturesError, + MarketIdMismatchError, + MidnightOfferMarketAddressMismatchError, + MidnightOfferMarketChainMismatchError, + MidnightOfferMarketLoanTokenMismatchError, + MidnightOfferRootOfferCountMismatchError, + MidnightOfferRootOwnerMismatchError, + MidnightOfferRootRatifierMismatchError, + MidnightOfferSideMismatchError, + MidnightRedeemExceedsCreditError, + MissingAccrualPositionError, + UnexpectedRequirementSignatureError, +} from "../../types/error.js"; +import { MorphoMidnight } from "./midnight.js"; +import type { MidnightActionSignatures, OffersData } from "./types.js"; + +type BuildSubmitOffersTx = (params: { + readonly offersData: OffersData; + readonly signatures?: MidnightActionSignatures; + readonly metadata?: { readonly origin: string }; +}) => Readonly>; + +const buildSubmitOffersTx: BuildSubmitOffersTx = (params) => + ( + Object.assign(Object.create(MorphoMidnight.prototype), { + chainId: midnightChainId, + client: { + options: { + metadata: params.metadata, + }, + }, + }) as { + buildSubmitOffersTx: BuildSubmitOffersTx; + } + ).buildSubmitOffersTx(params); + +const offersData = ( + buy = true, + maker: Address = midnightAddresses.maker, +): OffersData => { + const offer = Offer.create( + midnightBaseOffer({ + market: { ...midnightMarket, maturity: apiValidMaturity }, + buy, + maker, + expiry: apiValidMaturity - 60n, + maxAssets: 1_000n, + maxUnits: 0n, + ratifier: midnightAddresses.ecrecoverRatifier, + receiverIfMakerIsSeller: buy ? zeroAddress : maker, + }), + ); + const group = Group.create([offer]); + + return { + accountAddress: maker, + groups: [group.id], + tree: Tree.create([group]), + ratifierType: "ecrecover", + ratifier: midnightAddresses.ecrecoverRatifier, + }; +}; + +const multiGroupOffersData = (): OffersData => { + const lendOffer = Offer.create( + midnightBaseOffer({ + market: { ...midnightMarket, maturity: apiValidMaturity }, + buy: true, + expiry: apiValidMaturity - 60n, + maxAssets: 1_000n, + maxUnits: 0n, + ratifier: midnightAddresses.ecrecoverRatifier, + }), + ); + const borrowOffer = Offer.create( + midnightBaseOffer({ + market: { ...midnightMarket, maturity: apiValidMaturity }, + buy: false, + tick: 5_004n, + expiry: apiValidMaturity - 60n, + maxAssets: 1_000n, + maxUnits: 0n, + ratifier: midnightAddresses.ecrecoverRatifier, + }), + ); + const lendGroup = Group.create([lendOffer]); + const borrowGroup = Group.create([borrowOffer]); + + return { + accountAddress: midnightAddresses.maker, + groups: [lendGroup.id, borrowGroup.id], + tree: Tree.create([lendGroup, borrowGroup]), + ratifierType: "ecrecover", + ratifier: midnightAddresses.ecrecoverRatifier, + }; +}; + +const setterOffersData = (): OffersData => { + const offer = Offer.create( + midnightBaseOffer({ + market: { ...midnightMarket, maturity: apiValidMaturity }, + buy: true, + expiry: apiValidMaturity - 60n, + maxAssets: 1_000n, + maxUnits: 0n, + ratifier: midnightAddresses.setterRatifier, + }), + ); + const group = Group.create([offer]); + + return { + accountAddress: midnightAddresses.maker, + groups: [group.id], + tree: Tree.create([group]), + ratifierType: "setter", + ratifier: midnightAddresses.setterRatifier, + setterPayload: "0x1234", + }; +}; + +const offerRootSignature = ( + data: OffersData, + overrides: { + readonly owner?: Address; + readonly ratifier?: Address; + readonly offers?: number; + } = {}, +): MidnightOfferRootSignature => ({ + action: { + type: "midnightOfferRootSignature", + args: { + root: data.tree.root, + ratifier: overrides.ratifier ?? data.ratifier, + offers: overrides.offers ?? data.tree.offers.length, + }, + }, + args: { + owner: overrides.owner ?? data.accountAddress, + root: data.tree.root, + signature: "0x1234", + payload: "0x1234", + }, +}); + +const tokenSignature = { + action: { + type: "permit2Transfer", + args: { + spender: midnightAddresses.midnightBundles, + amount: 1_000n, + deadline: 123n, + }, + }, + args: { + owner: midnightAddresses.taker, + nonce: 42n, + asset: midnightAddresses.loanToken, + signature: "0x1234", + amount: 1_000n, + deadline: 123n, + }, +} satisfies TokenRequirementSignature; + +const client = { + viemClient: { chain: { id: midnightChainId } }, + options: {}, +} as unknown as MorphoClientType; + +const midnightTestChain = { + id: midnightChainId, + name: "Midnight Test", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: { default: { http: ["http://localhost"] } }, +} as const satisfies Chain; + +const apiValidMaturity = 1_767_279_600n; +const offerValidation = { + apiUrl: "https://api.example/base/", + fetch: async () => + new Response(JSON.stringify({ data: { issues: [] } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), +}; + +const marketData = (overrides: { readonly withdrawable?: bigint } = {}) => + new Market({ + params: midnightMarket, + totalUnits: 1_000n, + lossFactor: 0n, + withdrawable: overrides.withdrawable ?? 1_000n, + continuousFeeCredit: 0n, + settlementFeeCbps: [0, 0, 0, 0, 0, 0, 0], + continuousFee: 0, + tickSpacing: 1, + }); + +const positionData = ( + market: Market, + overrides: { readonly credit?: bigint; readonly pendingFee?: bigint } = {}, +) => + new AccrualPosition( + { + credit: overrides.credit ?? 100n, + pendingFee: overrides.pendingFee ?? 0n, + lastLossFactor: 0n, + lastAccrual: 0n, + debt: 0n, + collateralBitmap: 0n, + collateral: [], + }, + market, + ); + +const midnight = () => new MorphoMidnight(client, midnightChainId); + +describe("MorphoMidnight", () => { + describe("takeLend", () => { + test("default", () => { + const output = midnight().takeLend({ + marketData: marketData(), + accountAddress: midnightAddresses.taker, + assets: 1_000n, + minUnits: 900n, + takeableOffers: [midnightApiTake()], + deadline: maxUint256, + }); + const tx = output.buildTx(); + + expect(tx.action.args).toEqual({ + market: midnightMarketId, + assets: 1_000n, + minUnits: 900n, + taker: midnightAddresses.taker, + reduceOnly: false, + takeableOffers: 1, + collateralWithdrawals: 0, + collateralReceiver: zeroAddress, + referralFeePct: 0n, + referralFeeRecipient: zeroAddress, + maxContinuousFee: maxUint256, + deadline: maxUint256, + }); + }); + + test("error: MidnightOfferSideMismatchError", () => { + const output = midnight().takeLend({ + marketData: marketData(), + accountAddress: midnightAddresses.taker, + assets: 1_000n, + minUnits: 900n, + takeableOffers: [midnightApiTake({ buy: true })], + deadline: maxUint256, + }); + + expect(() => output.buildTx()).toThrow(MidnightOfferSideMismatchError); + }); + }); + + describe("takeBorrow", () => { + test("error: MidnightOfferSideMismatchError", () => { + const output = midnight().takeBorrow({ + marketData: marketData(), + accountAddress: midnightAddresses.taker, + loanAssets: 1_000n, + maxUnits: 900n, + takeableOffers: [midnightApiTake({ buy: false })], + deadline: maxUint256, + }); + + expect(() => output.buildTx()).toThrow(MidnightOfferSideMismatchError); + }); + }); + + describe("redeem", () => { + test("default", () => { + const market = marketData(); + const output = midnight().redeem({ + marketData: market, + positionData: positionData(market, { credit: 250n, pendingFee: 50n }), + accountAddress: midnightAddresses.taker, + }); + const tx = output.buildTx(); + + expect(tx.action.args).toEqual({ + market: midnightMarketId, + units: 200n, + onBehalf: midnightAddresses.taker, + receiver: midnightAddresses.taker, + }); + }); + + test("behavior: explicit units override face value", () => { + const market = marketData(); + const output = midnight().redeem({ + marketData: market, + positionData: positionData(market, { credit: 250n, pendingFee: 50n }), + accountAddress: midnightAddresses.taker, + units: 125n, + }); + const tx = output.buildTx(); + + expect(tx.action.args).toEqual({ + market: midnightMarketId, + units: 125n, + onBehalf: midnightAddresses.taker, + receiver: midnightAddresses.taker, + }); + }); + + test("behavior: explicit units can exceed face value up to accrued credit", () => { + const market = marketData(); + const output = midnight().redeem({ + marketData: market, + positionData: positionData(market, { credit: 250n, pendingFee: 50n }), + accountAddress: midnightAddresses.taker, + units: 225n, + }); + const tx = output.buildTx(); + + expect(tx.action.args.units).toBe(225n); + }); + + test("error: MarketIdMismatchError", () => { + const market = marketData(); + const otherMarket = new Market({ + ...market, + params: midnightOtherMarket, + }); + + expect(() => + midnight().redeem({ + marketData: market, + positionData: positionData(otherMarket), + accountAddress: midnightAddresses.taker, + }), + ).toThrow(MarketIdMismatchError); + }); + + test("error: MissingAccrualPositionError", () => { + const market = marketData(); + + expect(() => + midnight().redeem({ + marketData: market, + positionData: undefined as unknown as AccrualPosition, + accountAddress: midnightAddresses.taker, + }), + ).toThrow(MissingAccrualPositionError); + }); + + test("error: MidnightRedeemExceedsCreditError", () => { + const market = marketData(); + + expect(() => + midnight().redeem({ + marketData: market, + positionData: positionData(market, { credit: 250n, pendingFee: 50n }), + accountAddress: midnightAddresses.taker, + units: 251n, + }), + ).toThrow(MidnightRedeemExceedsCreditError); + }); + }); + + describe("getPositionData", () => { + test("behavior: pins position reads to the fetched block", async () => { + const handle = createMockClient(midnightTestChain); + const blockNumber = 123n; + const blockTimestamp = 1_500n; + handle.request.mockImplementation(async ({ method, params }) => { + if (method === "eth_chainId") return numberToHex(midnightChainId); + if (method === "eth_getBlockByNumber") { + return { + number: numberToHex(blockNumber), + timestamp: numberToHex(blockTimestamp), + transactions: [], + }; + } + if (method === "eth_call") { + const [tx] = (params ?? []) as [ + { readonly to?: Address; readonly data?: `0x${string}` }, + ]; + if (typeof tx?.to === "string" && typeof tx.data === "string") { + const encoded = handle.dispatch.get( + `${tx.to.toLowerCase()}|${tx.data.slice(0, 10).toLowerCase()}`, + ); + if (encoded != null) return encoded; + } + } + + throw new Error(`unhandled RPC ${method} ${JSON.stringify(params)}`); + }); + mockRead(handle, { + address: midnightAddresses.midnight, + abi: midnightAbi, + functionName: "position", + result: [1_000n, 0n, 0n, 1_000n, 0n, 0n], + }); + mockRead(handle, { + address: midnightAddresses.midnight, + abi: midnightAbi, + functionName: "collateral", + result: 0n, + }); + mockRead(handle, { + address: midnightAddresses.midnight, + abi: midnightAbi, + functionName: "toMarket", + result: MarketUtils.toStruct(midnightMarket), + }); + mockRead(handle, { + address: midnightAddresses.midnight, + abi: midnightAbi, + functionName: "marketState", + result: [1_000n, 0n, 1_000n, 0n, 0, 0, 0, 0, 0, 0, 0, 0, 1], + }); + + const position = await new MorphoMidnight( + { + viemClient: handle.client, + options: { supportDeployless: false }, + } as unknown as MorphoClientType, + midnightChainId, + ).getPositionData({ + marketId: midnightMarketId, + accountAddress: midnightAddresses.taker, + }); + + expect(position.lastAccrual).toBe(blockTimestamp); + expect( + handle.request.mock.calls + .map(([call]) => call) + .filter((call) => call.method === "eth_call") + .every((call) => call.params?.[1] === numberToHex(blockNumber)), + ).toBe(true); + }); + }); + + describe("getOffersData", () => { + test("behavior: accepts multiple Tree.create entries", async () => { + const lendOffer = Offer.create( + midnightBaseOffer({ + market: { ...midnightMarket, maturity: apiValidMaturity }, + buy: true, + expiry: apiValidMaturity - 60n, + maxAssets: 1_000n, + maxUnits: 0n, + ratifier: midnightAddresses.ecrecoverRatifier, + }), + ); + const borrowOffer = Offer.create( + midnightBaseOffer({ + market: { ...midnightMarket, maturity: apiValidMaturity }, + buy: false, + tick: 5_004n, + expiry: apiValidMaturity - 60n, + maxAssets: 1_000n, + maxUnits: 0n, + ratifier: midnightAddresses.ecrecoverRatifier, + }), + ); + const lendGroup = Group.create([lendOffer]); + const borrowGroup = Group.create([borrowOffer]); + const data = await midnight().getOffersData({ + accountAddress: midnightAddresses.maker, + offers: [lendGroup, borrowGroup], + validation: offerValidation, + }); + + expect(data.groups).toEqual([lendGroup.id, borrowGroup.id]); + expect(data.tree.offers).toHaveLength(2); + expect(data.ratifierType).toBe("ecrecover"); + expect(data.ratifier).toBe(midnightAddresses.ecrecoverRatifier); + }); + + test("behavior: accepts a single offer", async () => { + const offer = Offer.create( + midnightBaseOffer({ + market: { ...midnightMarket, maturity: apiValidMaturity }, + buy: true, + expiry: apiValidMaturity - 60n, + maxAssets: 1_000n, + maxUnits: 0n, + ratifier: midnightAddresses.ecrecoverRatifier, + }), + ); + const data = await midnight().getOffersData({ + accountAddress: midnightAddresses.maker, + offers: offer, + validation: offerValidation, + }); + + expect(data.groups).toEqual([offer.group]); + expect(data.tree.offers).toHaveLength(1); + }); + + test("error: MidnightOfferMarketChainMismatchError", async () => { + const offer = Offer.create( + midnightBaseOffer({ + market: { ...midnightMarket, chainId: 1n }, + buy: true, + maxAssets: 1_000n, + maxUnits: 0n, + ratifier: midnightAddresses.ecrecoverRatifier, + }), + ); + + await expect( + midnight().getOffersData({ + accountAddress: midnightAddresses.maker, + offers: offer, + validation: offerValidation, + }), + ).rejects.toThrow(MidnightOfferMarketChainMismatchError); + }); + + test("error: MidnightOfferMarketAddressMismatchError", async () => { + const offer = Offer.create( + midnightBaseOffer({ + market: { ...midnightMarket, midnight: zeroAddress }, + buy: true, + maxAssets: 1_000n, + maxUnits: 0n, + ratifier: midnightAddresses.ecrecoverRatifier, + }), + ); + + await expect( + midnight().getOffersData({ + accountAddress: midnightAddresses.maker, + offers: offer, + validation: offerValidation, + }), + ).rejects.toThrow(MidnightOfferMarketAddressMismatchError); + }); + }); + + describe("makeLend", () => { + test("default", async () => { + const data = offersData(true); + const output = await midnight().makeLend({ + accountAddress: data.accountAddress, + offers: data.tree, + validation: offerValidation, + loanToken: midnightAddresses.loanToken, + loanAssets: 1_000n, + }); + const tx = output.buildTx(offerRootSignature(data)); + + expect(output.groups).toEqual(data.groups); + expect(output.root).toBe(data.tree.root); + expect(output.ratifierType).toBe("ecrecover"); + expect(tx.action.args).toMatchObject({ + groups: data.groups, + root: data.tree.root, + maker: midnightAddresses.maker, + ratifier: midnightAddresses.ecrecoverRatifier, + ratifierType: "ecrecover", + offers: data.tree.offers.length, + }); + }); + + test("behavior: signs reviewable offer tree typed data", async () => { + const account = privateKeyToAccount( + "0x0000000000000000000000000000000000000000000000000000000000000001", + ); + let capturedOfferTreeTypedData: + | { + readonly primaryType?: string; + readonly types?: { + readonly OfferTree?: readonly { + readonly name: string; + readonly type: string; + }[]; + }; + readonly message?: { + readonly root?: Hex; + readonly offerTree?: { + readonly maker?: Address; + readonly ratifier?: Address; + readonly market?: { readonly loanToken?: Address }; + }; + }; + } + | undefined; + const walletClient = createWalletClient({ + account: account.address, + chain: midnightTestChain, + transport: custom({ + request: async ({ method, params }) => { + if ( + method !== "eth_signTypedData_v4" || + !Array.isArray(params) || + typeof params[1] !== "string" + ) { + throw new Error("Unexpected RPC request"); + } + const typedData = JSON.parse(params[1]) as NonNullable< + typeof capturedOfferTreeTypedData + > & + Parameters[0]; + capturedOfferTreeTypedData = typedData; + + return account.signTypedData(typedData); + }, + }), + }); + const handle = createMockClient(midnightTestChain); + mockRead(handle, { + address: midnightAddresses.loanToken, + abi: erc20Abi, + functionName: "allowance", + result: maxUint256, + }); + mockRead(handle, { + address: midnightAddresses.midnight, + abi: midnightAbi, + functionName: "isAuthorized", + result: true, + }); + + const data = offersData(true, account.address); + const output = await new MorphoMidnight( + { + viemClient: handle.client, + options: {}, + } as unknown as MorphoClientType, + midnightChainId, + ).makeLend({ + accountAddress: data.accountAddress, + offers: data.tree, + validation: offerValidation, + loanToken: midnightAddresses.loanToken, + loanAssets: 1_000n, + }); + const requirements = await output.getRequirements(); + const requirement = requirements.find( + ({ action }) => action.type === "midnightOfferRootSignature", + ); + if (requirement == null || !("sign" in requirement)) { + throw new Error("Expected midnightOfferRootSignature requirement"); + } + + const signature = await requirement.sign(walletClient, account.address); + if (signature.action.type !== "midnightOfferRootSignature") { + throw new Error("Expected midnightOfferRootSignature result"); + } + const message = capturedOfferTreeTypedData?.message; + + expect(signature.action.args.root).toBe(data.tree.root); + expect(capturedOfferTreeTypedData?.primaryType).toBe("OfferTree"); + expect(capturedOfferTreeTypedData?.types?.OfferTree?.[0]).toEqual({ + name: "offerTree", + type: "Offer", + }); + expect(message?.root).toBeUndefined(); + expect(message?.offerTree).toMatchObject({ + maker: account.address, + ratifier: data.ratifier, + market: { + loanToken: midnightAddresses.loanToken, + }, + }); + }); + + test("behavior: approval covers new group and existing loan reserves", async () => { + const handle = createMockClient(midnightTestChain); + mockRead(handle, { + address: midnightAddresses.loanToken, + abi: erc20Abi, + functionName: "allowance", + result: 0n, + }); + mockRead(handle, { + address: midnightAddresses.midnight, + abi: midnightAbi, + functionName: "isAuthorized", + result: true, + }); + + const data = offersData(true); + const output = await new MorphoMidnight( + { + viemClient: handle.client, + options: {}, + } as unknown as MorphoClientType, + midnightChainId, + ).makeLend({ + accountAddress: data.accountAddress, + offers: data.tree, + validation: offerValidation, + loanToken: midnightAddresses.loanToken, + loanAssets: 1_000n, + reservedLoanAssets: 250n, + }); + const requirements = await output.getRequirements(); + + expect( + requirements.find( + (requirement) => requirement.action.type === "erc20Approval", + )?.action, + ).toMatchObject({ + args: { + spender: midnightAddresses.midnight, + amount: 1_250n, + }, + }); + }); + + test("error: MidnightOfferSideMismatchError", async () => { + await expect( + midnight().makeLend({ + accountAddress: midnightAddresses.maker, + offers: offersData(false).tree, + validation: offerValidation, + loanToken: midnightAddresses.loanToken, + loanAssets: 1_000n, + }), + ).rejects.toThrow(MidnightOfferSideMismatchError); + }); + + test("error: MidnightOfferMarketLoanTokenMismatchError", async () => { + const offer = Offer.create( + midnightBaseOffer({ + market: { + ...midnightMarket, + loanToken: midnightAddresses.dai, + maturity: apiValidMaturity, + }, + buy: true, + expiry: apiValidMaturity - 60n, + maxAssets: 1_000n, + maxUnits: 0n, + ratifier: midnightAddresses.ecrecoverRatifier, + }), + ); + + await expect( + midnight().makeLend({ + accountAddress: midnightAddresses.maker, + offers: offer, + validation: offerValidation, + loanToken: midnightAddresses.loanToken, + loanAssets: 1_000n, + }), + ).rejects.toThrow(MidnightOfferMarketLoanTokenMismatchError); + }); + }); + + describe("supplyCollateralMakeBorrow", () => { + test("behavior: approval covers new group and existing collateral reserves", async () => { + const handle = createMockClient(midnightTestChain); + mockRead(handle, { + address: midnightAddresses.collateralToken, + abi: erc20Abi, + functionName: "allowance", + result: 0n, + }); + mockRead(handle, { + address: midnightAddresses.midnight, + abi: midnightAbi, + functionName: "isAuthorized", + result: true, + }); + + const data = offersData(false); + const output = await new MorphoMidnight( + { + viemClient: handle.client, + options: {}, + } as unknown as MorphoClientType, + midnightChainId, + ).supplyCollateralMakeBorrow({ + accountAddress: data.accountAddress, + offers: data.tree, + validation: offerValidation, + market: midnightMarket, + collateralAssets: 1_000n, + reservedCollateralAssets: 250n, + }); + const requirements = await output.getRequirements(); + + expect( + requirements.find( + (requirement) => requirement.action.type === "erc20Approval", + )?.action, + ).toMatchObject({ + args: { + spender: midnightAddresses.midnight, + amount: 1_250n, + }, + }); + expect( + requirements.find( + (requirement) => + requirement.action.type === "midnightSupplyCollateral", + )?.action, + ).toMatchObject({ + args: { + assets: 1_000n, + }, + }); + }); + }); + + describe("makeBorrow", () => { + test("default", async () => { + const data = offersData(false); + const output = await midnight().makeBorrow({ + accountAddress: data.accountAddress, + offers: data.tree, + validation: offerValidation, + }); + const tx = output.buildTx(offerRootSignature(data)); + + expect(output.groups).toEqual(data.groups); + expect(tx.action.args.maker).toBe(midnightAddresses.maker); + expect(tx.action.args.offers).toBe(data.tree.offers.length); + }); + + test("error: MidnightOfferSideMismatchError", async () => { + await expect( + midnight().makeBorrow({ + accountAddress: midnightAddresses.maker, + offers: offersData(true).tree, + validation: offerValidation, + }), + ).rejects.toThrow(MidnightOfferSideMismatchError); + }); + + test("error: MidnightOfferSideMismatchError mixed-side groups", async () => { + const data = multiGroupOffersData(); + + await expect( + midnight().makeBorrow({ + accountAddress: data.accountAddress, + offers: data.tree, + validation: offerValidation, + }), + ).rejects.toThrow(MidnightOfferSideMismatchError); + }); + }); + + describe("buildSubmitOffersTx", () => { + test("default", () => { + const data = offersData(); + const tx = buildSubmitOffersTx({ + offersData: data, + signatures: offerRootSignature(data), + }); + + expect(tx.action.args).toEqual({ + groups: data.groups, + root: data.tree.root, + maker: midnightAddresses.maker, + ratifier: midnightAddresses.ecrecoverRatifier, + ratifierType: "ecrecover", + offers: data.tree.offers.length, + }); + }); + + test("behavior: appends metadata", () => { + const data = offersData(); + const tx = buildSubmitOffersTx({ + offersData: data, + signatures: offerRootSignature(data), + metadata: { origin: "a1b2c3d4" }, + }); + + expect(tx.action.type).toBe("mempoolSubmitOffers"); + expect(tx.data.includes("a1b2c3d4")).toBe(true); + }); + + test("error: MidnightOfferRootOwnerMismatchError", () => { + const data = offersData(); + + expect(() => + buildSubmitOffersTx({ + offersData: data, + signatures: offerRootSignature(data, { + owner: midnightAddresses.taker, + }), + }), + ).toThrow(MidnightOfferRootOwnerMismatchError); + }); + + test("error: MidnightOfferRootRatifierMismatchError", () => { + const data = offersData(); + + expect(() => + buildSubmitOffersTx({ + offersData: data, + signatures: offerRootSignature(data, { + ratifier: midnightAddresses.setterRatifier, + }), + }), + ).toThrow(MidnightOfferRootRatifierMismatchError); + }); + + test("error: MidnightOfferRootOfferCountMismatchError", () => { + const data = offersData(); + + expect(() => + buildSubmitOffersTx({ + offersData: data, + signatures: offerRootSignature(data, { + offers: data.tree.offers.length + 1, + }), + }), + ).toThrow(MidnightOfferRootOfferCountMismatchError); + }); + + test("error: AmbiguousRequirementSignaturesError", () => { + const data = offersData(); + const signature = offerRootSignature(data); + + expect(() => + buildSubmitOffersTx({ + offersData: data, + signatures: [signature, signature], + }), + ).toThrow(AmbiguousRequirementSignaturesError); + }); + + test("error: UnexpectedRequirementSignatureError", () => { + const data = offersData(); + + expect(() => + buildSubmitOffersTx({ + offersData: data, + signatures: [tokenSignature], + }), + ).toThrow(UnexpectedRequirementSignatureError); + }); + + test("error: UnexpectedRequirementSignatureError for setter ratifier", () => { + const data = setterOffersData(); + + expect(() => + buildSubmitOffersTx({ + offersData: data, + signatures: [offerRootSignature(data)], + }), + ).toThrow(UnexpectedRequirementSignatureError); + }); + }); +}); diff --git a/packages/morpho-sdk/src/entities/midnight/midnight.ts b/packages/morpho-sdk/src/entities/midnight/midnight.ts new file mode 100644 index 000000000..e36667eac --- /dev/null +++ b/packages/morpho-sdk/src/entities/midnight/midnight.ts @@ -0,0 +1,944 @@ +import { + type AccrualPosition, + EcrecoverRatifierUtils, + fetchAccrualPosition, + fetchMarket, + type Market, + MarketParams, + MarketUtils, + type MidnightFetchParams, + Payload, + SetterRatifierUtils, + Tree, +} from "@morpho-org/midnight-sdk"; +import { deepFreeze, getChainAddress } from "@morpho-org/morpho-ts"; +import { + type Address, + type Hex, + isAddressEqual, + type TypedDataDefinition, + type WalletClient, +} from "viem"; +import { getBlock } from "viem/actions"; +import { + mempoolSubmitOffers, + midnightCancelOffer, + midnightRedeem, + midnightRepayWithdrawCollateral, + midnightSupplyCollateral, + midnightSupplyCollateralTakeBorrow, + midnightTakeBorrow, + midnightTakeLend, +} from "../../actions/midnight/index.js"; +import { + getMidnightApprovalRequirements, + getMidnightAuthorizationRequirement, + getMidnightBundlesRequirements, + getSetterRatifierRatifyRootRequirement, +} from "../../actions/requirements/index.js"; +import { validateChainId } from "../../helpers/index.js"; +import { signAndVerifyTypedData } from "../../helpers/signAndVerifyTypedData.js"; +import { validateOfferSides } from "../../helpers/validateOfferSides.js"; +import type { MorphoClientType } from "../../types/client.js"; +import { + type ActionOutput, + type ActionRequirement, + InsufficientMidnightWithdrawableLiquidityError, + MarketIdMismatchError, + type MidnightCancelOfferAction, + MidnightOfferMarketAddressMismatchError, + MidnightOfferMarketChainMismatchError, + MidnightOfferMarketLoanTokenMismatchError, + MidnightOfferRootMismatchError, + MidnightOfferRootOfferCountMismatchError, + MidnightOfferRootOwnerMismatchError, + MidnightOfferRootRatifierMismatchError, + type MidnightOfferRootSignatureAction, + type MidnightRedeemAction, + MidnightRedeemExceedsCreditError, + type MidnightRepayWithdrawCollateralAction, + type MidnightSupplyCollateralAction, + type MidnightSupplyCollateralTakeBorrowAction, + type MidnightTakeBorrowAction, + type MidnightTakeLendAction, + MissingAccrualPositionError, + MissingMidnightOfferRootSignatureError, + NegativeMidnightAmountError, + NoMidnightCreditToRedeemError, + NonPositiveMidnightAmountError, + selectRequirementSignatures, + UnknownMidnightRatifierError, +} from "../../types/index.js"; +import type { + GetOffersDataParams, + GetPositionDataParams, + MakeLendParams, + MakeOffersOutput, + MakeOffersParams, + MidnightActionSignatures, + MidnightRequirementsParams, + OffersData, + RedeemParams, + RepayWithdrawCollateralParams, + SupplyCollateralMakeBorrowParams, + SupplyCollateralParams, + SupplyCollateralTakeBorrowParams, + TakeBorrowParams, + TakeLendParams, +} from "./types.js"; + +/** Midnight entity methods exposed by `client.morpho.midnight(chainId)`. */ +export interface MidnightActions { + getMarketData( + marketId: Hex, + parameters?: MidnightFetchParams, + ): Promise; + getPositionData(params: GetPositionDataParams): Promise; + getOffersData(params: GetOffersDataParams): Promise; + takeLend( + params: TakeLendParams, + ): ActionOutput; + takeBorrow( + params: TakeBorrowParams, + ): ActionOutput; + supplyCollateralTakeBorrow( + params: SupplyCollateralTakeBorrowParams, + ): ActionOutput< + MidnightSupplyCollateralTakeBorrowAction, + MidnightActionSignatures + >; + supplyCollateral( + params: SupplyCollateralParams, + ): ActionOutput; + makeLend(params: MakeLendParams): Promise; + makeBorrow(params: MakeOffersParams): Promise; + supplyCollateralMakeBorrow( + params: SupplyCollateralMakeBorrowParams, + ): Promise; + redeem(params: RedeemParams): ActionOutput; + repayWithdrawCollateral( + params: RepayWithdrawCollateralParams, + ): ActionOutput< + MidnightRepayWithdrawCollateralAction, + MidnightActionSignatures + >; + cancelOffer(params: { + readonly group: Hex; + readonly accountAddress: Address; + }): ActionOutput; +} + +const assertNonNegativeAmount = (label: string, amount: bigint) => { + if (amount < 0n) throw new NegativeMidnightAmountError(label, amount); +}; + +const assertPositiveAmount = (label: string, amount: bigint) => { + if (amount <= 0n) throw new NonPositiveMidnightAmountError(label, amount); +}; + +const validateMarketData = (market: Market, chainId: number) => { + validateChainId(Number(market.chainId), chainId); +}; + +/** Entity facade for Midnight Midnight action flows. */ +export class MorphoMidnight implements MidnightActions { + constructor( + private readonly client: MorphoClientType, + private readonly chainId: number, + ) {} + + async getMarketData( + marketId: Hex, + parameters?: MidnightFetchParams, + ): Promise { + validateChainId(this.client.viemClient.chain?.id, this.chainId); + + return await fetchMarket(this.client.viemClient, { + ...parameters, + marketId, + }); + } + + async getPositionData( + params: GetPositionDataParams, + ): Promise { + validateChainId(this.client.viemClient.chain?.id, this.chainId); + const parameters = params.parameters ?? {}; + const blockParameters = + parameters.blockNumber != null + ? { blockNumber: parameters.blockNumber } + : parameters.blockTag != null + ? { blockTag: parameters.blockTag } + : {}; + const block = await getBlock(this.client.viemClient, blockParameters); + const { + blockNumber: _blockNumber, + blockTag: _blockTag, + ...fetchParams + } = parameters; + const fetchBlockParameters = + block.number != null ? { blockNumber: block.number } : blockParameters; + + const position = await fetchAccrualPosition(this.client.viemClient, { + ...fetchParams, + deployless: this.client.options.supportDeployless, + ...fetchBlockParameters, + marketId: params.marketId, + user: params.accountAddress, + }); + + return position.accrueInterest(block.timestamp); + } + + async getOffersData(params: GetOffersDataParams): Promise { + validateChainId(this.client.viemClient.chain?.id, this.chainId); + const tree = Tree.from(params.offers); + const midnight = getChainAddress(this.chainId, "midnight"); + tree.offers.forEach((offer, index) => { + const market = + "params" in offer.market ? offer.market.params : offer.market; + if (market.chainId !== BigInt(this.chainId)) { + throw new MidnightOfferMarketChainMismatchError({ + index, + expectedChainId: this.chainId, + actualChainId: market.chainId, + }); + } + if (!isAddressEqual(market.midnight, midnight)) { + throw new MidnightOfferMarketAddressMismatchError({ + index, + expectedMidnight: midnight, + actualMidnight: market.midnight, + }); + } + }); + const ratifier = tree.offers[0]!.ratifier; + const ecrecoverRatifier = getChainAddress( + this.chainId, + "ecrecoverRatifier", + ); + const setterRatifier = getChainAddress(this.chainId, "setterRatifier"); + const ratifierType = isAddressEqual(ratifier, ecrecoverRatifier) + ? "ecrecover" + : isAddressEqual(ratifier, setterRatifier) + ? "setter" + : undefined; + if (ratifierType == null) { + throw new UnknownMidnightRatifierError({ + ratifier, + ecrecoverRatifier, + setterRatifier, + }); + } + + const groups: Hex[] = []; + const seenGroups = new Set(); + for (const offer of tree.offers) { + const group = offer.group; + const key = group.toLowerCase(); + if (!seenGroups.has(key)) { + seenGroups.add(key); + groups.push(group); + } + } + + await tree.mempoolValidate({ + ...params.validation, + chainId: this.chainId, + }); + + if (ratifierType === "setter") { + // Setter ratifier payload generation validates that the created tree has one ratifier. + const items = SetterRatifierUtils.ratify({ tree }); + return { + accountAddress: params.accountAddress, + groups, + tree, + ratifierType, + ratifier, + setterPayload: await Payload.encode(items), + }; + } + // Ecrecover typed-data generation validates that the created tree has one ratifier. + EcrecoverRatifierUtils.typedData({ tree, chainId: this.chainId }); + + return { + accountAddress: params.accountAddress, + groups, + tree, + ratifierType, + ratifier, + }; + } + + takeLend(params: TakeLendParams) { + validateChainId(this.client.viemClient.chain?.id, this.chainId); + validateMarketData(params.marketData, this.chainId); + assertPositiveAmount("assets", params.assets); + assertNonNegativeAmount("minUnits", params.minUnits); + assertNonNegativeAmount("deadline", params.deadline); + + const market = params.marketData; + const midnightBundles = getChainAddress(this.chainId, "midnightBundles"); + + return { + getRequirements: async (reqParams?: MidnightRequirementsParams) => { + const requirements: ActionRequirement[] = [ + ...(await this.getTokenPullRequirements( + { + token: market.params.loanToken, + owner: params.accountAddress, + amount: params.assets, + }, + reqParams, + )), + ]; + const authorization = await getMidnightAuthorizationRequirement({ + viemClient: this.client.viemClient, + chainId: this.chainId, + owner: params.accountAddress, + authorized: midnightBundles, + }); + if (authorization) requirements.push(authorization); + + return requirements; + }, + buildTx: (signatures?: MidnightActionSignatures) => + midnightTakeLend({ + chainId: this.chainId, + market: market.params, + assets: params.assets, + minUnits: params.minUnits, + taker: params.accountAddress, + takeableOffers: params.takeableOffers, + reduceOnly: params.reduceOnly, + collateralWithdrawals: params.collateralWithdrawals, + collateralReceiver: params.collateralReceiver, + referralFeePct: params.referralFeePct, + referralFeeRecipient: params.referralFeeRecipient, + maxContinuousFee: params.maxContinuousFee, + deadline: params.deadline, + signatures, + metadata: this.client.options.metadata, + }), + }; + } + + takeBorrow(params: TakeBorrowParams) { + validateChainId(this.client.viemClient.chain?.id, this.chainId); + validateMarketData(params.marketData, this.chainId); + assertPositiveAmount("loanAssets", params.loanAssets); + assertNonNegativeAmount("maxUnits", params.maxUnits); + assertNonNegativeAmount("deadline", params.deadline); + + const market = params.marketData; + const midnightBundles = getChainAddress(this.chainId, "midnightBundles"); + + return { + getRequirements: async () => { + const requirements: ActionRequirement[] = []; + const authorization = await getMidnightAuthorizationRequirement({ + viemClient: this.client.viemClient, + chainId: this.chainId, + owner: params.accountAddress, + authorized: midnightBundles, + }); + if (authorization) requirements.push(authorization); + + return requirements; + }, + buildTx: () => + midnightTakeBorrow({ + chainId: this.chainId, + market: market.params, + loanAssets: params.loanAssets, + maxUnits: params.maxUnits, + taker: params.accountAddress, + takeableOffers: params.takeableOffers, + reduceOnly: params.reduceOnly, + receiver: params.receiver, + referralFeePct: params.referralFeePct, + referralFeeRecipient: params.referralFeeRecipient, + maxContinuousFee: params.maxContinuousFee, + deadline: params.deadline, + metadata: this.client.options.metadata, + }), + }; + } + + supplyCollateralTakeBorrow(params: SupplyCollateralTakeBorrowParams) { + validateChainId(this.client.viemClient.chain?.id, this.chainId); + validateMarketData(params.marketData, this.chainId); + assertPositiveAmount("collateralAssets", params.collateralAssets); + assertPositiveAmount("loanAssets", params.loanAssets); + assertNonNegativeAmount("maxUnits", params.maxUnits); + assertNonNegativeAmount("deadline", params.deadline); + + const market = params.marketData; + const collateralIndex = params.collateralIndex ?? 0n; + const midnightBundles = getChainAddress(this.chainId, "midnightBundles"); + const collateral = market.getCollateralByIndex(collateralIndex); + + return { + getRequirements: async (reqParams?: MidnightRequirementsParams) => { + const requirements: ActionRequirement[] = [ + ...(await this.getTokenPullRequirements( + { + token: collateral.token, + owner: params.accountAddress, + amount: params.collateralAssets, + }, + reqParams, + )), + ]; + const authorization = await getMidnightAuthorizationRequirement({ + viemClient: this.client.viemClient, + chainId: this.chainId, + owner: params.accountAddress, + authorized: midnightBundles, + }); + if (authorization) requirements.push(authorization); + + return requirements; + }, + buildTx: (signatures?: MidnightActionSignatures) => + midnightSupplyCollateralTakeBorrow({ + chainId: this.chainId, + market: market.params, + collateralAssets: params.collateralAssets, + loanAssets: params.loanAssets, + maxUnits: params.maxUnits, + taker: params.accountAddress, + collateralIndex, + takeableOffers: params.takeableOffers, + reduceOnly: params.reduceOnly, + receiver: params.receiver, + referralFeePct: params.referralFeePct, + referralFeeRecipient: params.referralFeeRecipient, + maxContinuousFee: params.maxContinuousFee, + deadline: params.deadline, + signatures, + metadata: this.client.options.metadata, + }), + }; + } + + supplyCollateral(params: SupplyCollateralParams) { + validateChainId(this.client.viemClient.chain?.id, this.chainId); + validateMarketData(params.marketData, this.chainId); + assertPositiveAmount("collateralAssets", params.collateralAssets); + assertNonNegativeAmount( + "reservedCollateralAssets", + params.reservedCollateralAssets ?? 0n, + ); + + const market = params.marketData; + const collateralIndex = params.collateralIndex ?? 0n; + const collateral = market.getCollateralByIndex(collateralIndex); + const midnight = getChainAddress(this.chainId, "midnight"); + + return { + getRequirements: async () => + await getMidnightApprovalRequirements({ + viemClient: this.client.viemClient, + chainId: this.chainId, + token: collateral.token, + owner: params.accountAddress, + spender: midnight, + amount: + params.collateralAssets + (params.reservedCollateralAssets ?? 0n), + }), + buildTx: () => + midnightSupplyCollateral({ + chainId: this.chainId, + market: market.params, + collateralIndex, + assets: params.collateralAssets, + onBehalf: params.accountAddress, + metadata: this.client.options.metadata, + }), + }; + } + + async makeLend(params: MakeLendParams): Promise { + validateChainId(this.client.viemClient.chain?.id, this.chainId); + assertPositiveAmount("loanAssets", params.loanAssets); + assertNonNegativeAmount( + "reservedLoanAssets", + params.reservedLoanAssets ?? 0n, + ); + + const data = await this.getOffersData({ + accountAddress: params.accountAddress, + offers: params.offers, + validation: params.validation, + }); + validateOfferSides(data.tree.offers, true); + data.tree.offers.forEach((offer, index) => { + const market = + "params" in offer.market ? offer.market.params : offer.market; + if (!isAddressEqual(market.loanToken, params.loanToken)) { + throw new MidnightOfferMarketLoanTokenMismatchError({ + index, + expectedLoanToken: params.loanToken, + actualLoanToken: market.loanToken, + }); + } + }); + const midnight = getChainAddress(this.chainId, "midnight"); + + return { + groups: data.groups, + root: data.tree.root, + ratifierType: data.ratifierType, + getRequirements: async () => { + const requirements: ActionRequirement[] = []; + requirements.push( + ...(await getMidnightApprovalRequirements({ + viemClient: this.client.viemClient, + chainId: this.chainId, + token: params.loanToken, + owner: data.accountAddress, + spender: midnight, + amount: params.loanAssets + (params.reservedLoanAssets ?? 0n), + })), + ); + requirements.push( + ...(await this.getRatifierRequirements({ + offersData: data, + })), + ); + + return requirements; + }, + buildTx: (signatures?: MidnightActionSignatures) => + this.buildSubmitOffersTx({ + offersData: data, + signatures, + }), + }; + } + + async makeBorrow(params: MakeOffersParams): Promise { + validateChainId(this.client.viemClient.chain?.id, this.chainId); + + const data = await this.getOffersData({ + accountAddress: params.accountAddress, + offers: params.offers, + validation: params.validation, + }); + validateOfferSides(data.tree.offers, false); + + return { + groups: data.groups, + root: data.tree.root, + ratifierType: data.ratifierType, + getRequirements: async () => { + return await this.getRatifierRequirements({ + offersData: data, + }); + }, + buildTx: (signatures?: MidnightActionSignatures) => + this.buildSubmitOffersTx({ + offersData: data, + signatures, + }), + }; + } + + async supplyCollateralMakeBorrow( + params: SupplyCollateralMakeBorrowParams, + ): Promise { + validateChainId(this.client.viemClient.chain?.id, this.chainId); + assertPositiveAmount("collateralAssets", params.collateralAssets); + assertNonNegativeAmount( + "reservedCollateralAssets", + params.reservedCollateralAssets ?? 0n, + ); + + const market = + params.market instanceof MarketParams + ? params.market + : MarketParams.from(params.market); + const collateralIndex = params.collateralIndex ?? 0n; + const collateral = MarketUtils.getCollateralByIndex( + market, + collateralIndex, + ); + + const data = await this.getOffersData({ + accountAddress: params.accountAddress, + offers: params.offers, + validation: params.validation, + }); + validateOfferSides(data.tree.offers, false); + const midnight = getChainAddress(this.chainId, "midnight"); + + return { + groups: data.groups, + root: data.tree.root, + ratifierType: data.ratifierType, + getRequirements: async () => { + const requirements: ActionRequirement[] = [ + ...(await getMidnightApprovalRequirements({ + viemClient: this.client.viemClient, + chainId: this.chainId, + token: collateral.token, + owner: data.accountAddress, + spender: midnight, + amount: + params.collateralAssets + (params.reservedCollateralAssets ?? 0n), + })), + midnightSupplyCollateral({ + chainId: this.chainId, + market, + collateralIndex, + assets: params.collateralAssets, + onBehalf: data.accountAddress, + metadata: this.client.options.metadata, + }), + ...(await this.getRatifierRequirements({ + offersData: data, + })), + ]; + + return requirements; + }, + buildTx: (signatures?: MidnightActionSignatures) => + this.buildSubmitOffersTx({ + offersData: data, + signatures, + }), + }; + } + + redeem(params: RedeemParams) { + validateChainId(this.client.viemClient.chain?.id, this.chainId); + validateMarketData(params.marketData, this.chainId); + if (!params.positionData) { + throw new MissingAccrualPositionError(params.marketData.id); + } + if ( + params.positionData.market.id.toLowerCase() !== + params.marketData.id.toLowerCase() + ) { + throw new MarketIdMismatchError( + params.positionData.market.id, + params.marketData.id, + ); + } + + const market = params.marketData; + const units = params.units ?? params.positionData.faceValue; + if (units <= 0n) throw new NoMidnightCreditToRedeemError(market.id); + if (params.positionData.credit < units) { + throw new MidnightRedeemExceedsCreditError({ + market: market.id, + units, + credit: params.positionData.credit, + }); + } + if (market.withdrawable < units) { + throw new InsufficientMidnightWithdrawableLiquidityError({ + market: market.id, + units, + withdrawable: market.withdrawable, + }); + } + + return { + getRequirements: async () => [], + buildTx: () => + midnightRedeem({ + chainId: this.chainId, + market: market.params, + units, + onBehalf: params.accountAddress, + receiver: params.receiver, + metadata: this.client.options.metadata, + }), + }; + } + + repayWithdrawCollateral(params: RepayWithdrawCollateralParams) { + validateChainId(this.client.viemClient.chain?.id, this.chainId); + validateMarketData(params.marketData, this.chainId); + assertNonNegativeAmount("repayAssets", params.repayAssets); + assertNonNegativeAmount( + "withdrawCollateralAssets", + params.withdrawCollateralAssets, + ); + assertNonNegativeAmount("deadline", params.deadline); + const collateralWithdrawals = + params.collateralWithdrawals ?? + (params.withdrawCollateralAssets > 0n + ? [ + { + collateralIndex: params.collateralIndex ?? 0n, + assets: params.withdrawCollateralAssets, + }, + ] + : []); + for (const [index, withdrawal] of collateralWithdrawals.entries()) { + assertNonNegativeAmount( + `collateralWithdrawals[${index}].collateralIndex`, + withdrawal.collateralIndex, + ); + assertNonNegativeAmount( + `collateralWithdrawals[${index}].assets`, + withdrawal.assets, + ); + } + if ( + params.repayAssets === 0n && + collateralWithdrawals.every((withdrawal) => withdrawal.assets === 0n) + ) { + throw new NonPositiveMidnightAmountError("repay or withdraw amount", 0n); + } + + const market = params.marketData; + const midnightBundles = getChainAddress(this.chainId, "midnightBundles"); + + return { + getRequirements: async (reqParams?: MidnightRequirementsParams) => { + const requirements: ActionRequirement[] = []; + if (params.repayAssets > 0n) { + requirements.push( + ...(await this.getTokenPullRequirements( + { + token: market.params.loanToken, + owner: params.accountAddress, + amount: params.repayAssets, + }, + reqParams, + )), + ); + } + const authorization = await getMidnightAuthorizationRequirement({ + viemClient: this.client.viemClient, + chainId: this.chainId, + owner: params.accountAddress, + authorized: midnightBundles, + }); + if (authorization) requirements.push(authorization); + + return requirements; + }, + buildTx: (signatures?: MidnightActionSignatures) => + midnightRepayWithdrawCollateral({ + chainId: this.chainId, + market: market.params, + repayAssets: params.repayAssets, + withdrawCollateralAssets: params.withdrawCollateralAssets, + onBehalf: params.accountAddress, + collateralIndex: params.collateralIndex, + receiver: params.receiver, + collateralReceiver: params.collateralReceiver, + collateralWithdrawals, + referralFeePct: params.referralFeePct, + referralFeeRecipient: params.referralFeeRecipient, + deadline: params.deadline, + signatures, + metadata: this.client.options.metadata, + }), + }; + } + + cancelOffer(params: { + readonly group: Hex; + readonly accountAddress: Address; + }) { + validateChainId(this.client.viemClient.chain?.id, this.chainId); + + return { + getRequirements: async () => [], + buildTx: () => + midnightCancelOffer({ + chainId: this.chainId, + group: params.group, + onBehalf: params.accountAddress, + metadata: this.client.options.metadata, + }), + }; + } + + private async getRatifierRequirements(params: { + readonly offersData: OffersData; + }): Promise { + const data = params.offersData; + const requirements: ActionRequirement[] = []; + const authorization = await getMidnightAuthorizationRequirement({ + viemClient: this.client.viemClient, + chainId: this.chainId, + owner: data.accountAddress, + authorized: data.ratifier, + }); + if (authorization) requirements.push(authorization); + + if (data.ratifierType === "ecrecover") { + const chainId = this.chainId; + const action: MidnightOfferRootSignatureAction = { + type: "midnightOfferRootSignature", + args: { + root: data.tree.root, + ratifier: data.ratifier, + offers: data.tree.offers.length, + }, + }; + + requirements.push({ + action, + async sign(client: WalletClient, userAddress: Address) { + const typedData = EcrecoverRatifierUtils.typedData({ + tree: data.tree, + chainId, + }); + const typedDataDefinition: TypedDataDefinition< + Record, + "OfferTree" + > = { + domain: typedData.domain, + types: typedData.types, + primaryType: typedData.primaryType, + message: typedData.message, + }; + const signature = await signAndVerifyTypedData({ + client, + userAddress, + typedData: typedDataDefinition, + }); + + const items = await EcrecoverRatifierUtils.ratify({ + tree: data.tree, + account: userAddress, + signature, + }); + const payload = await Payload.encode(items); + + return deepFreeze({ + args: { + owner: userAddress, + root: data.tree.root, + signature, + payload, + }, + action, + }); + }, + }); + return requirements; + } + + const ratifyRoot = await getSetterRatifierRatifyRootRequirement({ + viemClient: this.client.viemClient, + chainId: this.chainId, + maker: data.accountAddress, + root: data.tree.root, + }); + if (ratifyRoot) requirements.push(ratifyRoot); + + return requirements; + } + + private async getTokenPullRequirements( + params: { + readonly token: Address; + readonly owner: Address; + readonly amount: bigint; + }, + reqParams?: MidnightRequirementsParams, + ) { + if (this.client.options.supportSignature) { + return await getMidnightBundlesRequirements({ + viemClient: this.client.viemClient, + chainId: this.chainId, + supportDeployless: this.client.options.supportDeployless, + supportSignature: true, + useSimplePermit: reqParams?.useSimplePermit, + ...params, + }); + } + + return await getMidnightBundlesRequirements({ + viemClient: this.client.viemClient, + chainId: this.chainId, + supportDeployless: this.client.options.supportDeployless, + supportSignature: false, + ...params, + }); + } + + private buildSubmitOffersTx(params: { + readonly offersData: OffersData; + readonly signatures?: MidnightActionSignatures; + }) { + const data = params.offersData; + const collectedSignatures = + params.signatures == null + ? undefined + : "action" in params.signatures + ? [params.signatures] + : params.signatures; + let payload = data.setterPayload; + if (data.ratifierType === "ecrecover") { + const { midnightOfferRoot: signature } = selectRequirementSignatures( + collectedSignatures, + { midnightOfferRoot: true }, + ); + + if (signature == null) { + throw new MissingMidnightOfferRootSignatureError(); + } + if (!isAddressEqual(signature.args.owner, data.accountAddress)) { + throw new MidnightOfferRootOwnerMismatchError({ + expectedOwner: data.accountAddress, + actualOwner: signature.args.owner, + }); + } + if (signature.args.root.toLowerCase() !== data.tree.root.toLowerCase()) { + throw new MidnightOfferRootMismatchError({ + expectedRoot: data.tree.root, + actualRoot: signature.args.root, + }); + } + if ( + signature.action.args.root.toLowerCase() !== + data.tree.root.toLowerCase() + ) { + throw new MidnightOfferRootMismatchError({ + expectedRoot: data.tree.root, + actualRoot: signature.action.args.root, + }); + } + if (!isAddressEqual(signature.action.args.ratifier, data.ratifier)) { + throw new MidnightOfferRootRatifierMismatchError({ + expectedRatifier: data.ratifier, + actualRatifier: signature.action.args.ratifier, + }); + } + if (signature.action.args.offers !== data.tree.offers.length) { + throw new MidnightOfferRootOfferCountMismatchError({ + expectedOffers: data.tree.offers.length, + actualOffers: signature.action.args.offers, + }); + } + payload = signature.args.payload; + } else { + selectRequirementSignatures(collectedSignatures, {}); + } + + if (payload == null) throw new MissingMidnightOfferRootSignatureError(); + + return mempoolSubmitOffers({ + chainId: this.chainId, + groups: data.groups, + root: data.tree.root, + maker: data.accountAddress, + ratifier: data.ratifier, + ratifierType: data.ratifierType, + offers: data.tree.offers.length, + payload, + metadata: this.client.options.metadata, + }); + } +} diff --git a/packages/morpho-sdk/src/entities/midnight/types.ts b/packages/morpho-sdk/src/entities/midnight/types.ts new file mode 100644 index 000000000..3041ef574 --- /dev/null +++ b/packages/morpho-sdk/src/entities/midnight/types.ts @@ -0,0 +1,166 @@ +import type { + AccrualPosition, + Market, + MarketInput, + MidnightFetchParams, + Tree, + TreeInput, + TreeMempoolValidateParams, +} from "@morpho-org/midnight-sdk"; +import type { Address, Hex } from "viem"; +import type { + MidnightCollateralWithdrawal, + MidnightTakeableOffer, +} from "../../actions/midnight/types.js"; +import type { + ActionOutput, + AnyRequirementSignature, + MempoolSubmitOffersAction, +} from "../../types/action.js"; + +/** Optional Midnight API validation controls for make-offer flows. */ +export type OfferValidationParams = Omit; + +/** Parameters for building and validating Midnight offer data. */ +export interface GetOffersDataParams { + readonly accountAddress: Address; + readonly offers: TreeInput; + readonly validation?: OfferValidationParams; +} + +/** Prepared Midnight maker-offer data derived from a tree-like offer set. */ +export interface OffersData { + readonly accountAddress: Address; + readonly groups: readonly Hex[]; + readonly tree: Tree; + readonly ratifierType: "ecrecover" | "setter"; + readonly ratifier: Address; + readonly setterPayload?: Hex; +} + +/** Parameters shared by Midnight maker-offer flows. */ +export interface MakeOffersParams { + readonly accountAddress: Address; + readonly offers: TreeInput; + readonly validation?: OfferValidationParams; +} + +/** Parameters for the Midnight make-lend maker flow. */ +export interface MakeLendParams extends MakeOffersParams { + readonly loanToken: Address; + /** New group loan reserve. For grouped OCA offers, pass the group reserve once instead of summing every leg. */ + readonly loanAssets: bigint; + /** Existing loan assets reserved across the maker's other open groups, including consumed amounts when available. */ + readonly reservedLoanAssets?: bigint; +} + +/** Parameters for the Midnight supply-collateral-and-make-borrow maker flow. */ +export interface SupplyCollateralMakeBorrowParams extends MakeOffersParams { + readonly market: MarketInput; + /** Collateral supplied before offer submission and the new group collateral reserve counted once for grouped offers. */ + readonly collateralAssets: bigint; + /** Existing collateral assets reserved across the maker's other open groups, including consumed amounts when available. */ + readonly reservedCollateralAssets?: bigint; + readonly collateralIndex?: bigint; +} + +/** Requirement-resolution options accepted by Midnight action outputs. */ +export interface MidnightRequirementsParams { + /** + * Prefer the ERC-2612 simple-permit path when the SDK detects support. + * Leave unset or set to `false` to force the Permit2/classic approval fallback when + * a token is known to be incompatible despite passing the SDK's shallow `nonces` + * compatibility probe. + */ + readonly useSimplePermit?: boolean; +} + +/** Signatures accepted by Midnight action-output transaction builders. */ +export type MidnightActionSignatures = + | AnyRequirementSignature + | readonly AnyRequirementSignature[]; + +/** Output returned by maker-offer flows. */ +export interface MakeOffersOutput + extends ActionOutput { + readonly groups: readonly Hex[]; + readonly root: Hex; + readonly ratifierType: "ecrecover" | "setter"; +} + +/** Parameters shared by Midnight market action flows. */ +export interface MarketActionParams { + readonly accountAddress: Address; + readonly marketData: Market; +} + +/** Parameters for the Midnight take-lend taker flow. */ +export interface TakeLendParams extends MarketActionParams { + readonly assets: bigint; + readonly minUnits: bigint; + readonly takeableOffers: readonly MidnightTakeableOffer[]; + readonly reduceOnly?: boolean; + readonly collateralWithdrawals?: readonly MidnightCollateralWithdrawal[]; + readonly collateralReceiver?: Address; + readonly referralFeePct?: bigint; + readonly referralFeeRecipient?: Address; + readonly maxContinuousFee?: bigint; + /** Bundle execution deadline timestamp. Pass `maxUint256` explicitly for no expiry. */ + readonly deadline: bigint; +} + +/** Parameters for the Midnight take-borrow taker flow. */ +export interface TakeBorrowParams extends MarketActionParams { + readonly loanAssets: bigint; + readonly maxUnits: bigint; + readonly takeableOffers: readonly MidnightTakeableOffer[]; + readonly reduceOnly?: boolean; + readonly receiver?: Address; + readonly referralFeePct?: bigint; + readonly referralFeeRecipient?: Address; + readonly maxContinuousFee?: bigint; + /** Bundle execution deadline timestamp. Pass `maxUint256` explicitly for no expiry. */ + readonly deadline: bigint; +} + +/** Parameters for the Midnight supply-collateral-and-take-borrow taker flow. */ +export interface SupplyCollateralTakeBorrowParams extends TakeBorrowParams { + readonly collateralAssets: bigint; + readonly collateralIndex?: bigint; +} + +/** Parameters for the Midnight supply-collateral flow. */ +export interface SupplyCollateralParams extends MarketActionParams { + readonly collateralAssets: bigint; + /** Existing collateral assets reserved across the maker's open groups, including consumed amounts when available. */ + readonly reservedCollateralAssets?: bigint; + readonly collateralIndex?: bigint; +} + +/** Parameters for the Midnight redeem flow. */ +export interface RedeemParams extends MarketActionParams { + readonly positionData: AccrualPosition; + readonly receiver?: Address; + readonly units?: bigint; +} + +/** Parameters for the Midnight repay-and-withdraw-collateral flow. */ +export interface RepayWithdrawCollateralParams extends MarketActionParams { + readonly repayAssets: bigint; + readonly withdrawCollateralAssets: bigint; + readonly collateralIndex?: bigint; + readonly receiver?: Address; + readonly collateralReceiver?: Address; + readonly collateralWithdrawals?: readonly MidnightCollateralWithdrawal[]; + readonly referralFeePct?: bigint; + readonly referralFeeRecipient?: Address; + /** Bundle execution deadline timestamp. Pass `maxUint256` explicitly for no expiry. */ + readonly deadline: bigint; +} + +/** Parameters for fetching a Midnight user position with market data. */ +export interface GetPositionDataParams { + readonly marketId: Hex; + readonly accountAddress: Address; + readonly parameters?: MidnightFetchParams; +} diff --git a/packages/morpho-sdk/src/errors.ts b/packages/morpho-sdk/src/errors.ts index 1084bfcf9..4de123f19 100644 --- a/packages/morpho-sdk/src/errors.ts +++ b/packages/morpho-sdk/src/errors.ts @@ -15,5 +15,22 @@ export { UnsupportedVaultV2AdapterError, VaultV2Errors, } from "@morpho-org/blue-sdk"; +export { + InvalidMidnightApiResponseError, + InvalidOfferGroupError, + InvalidOfferParameterError, + InvalidPositionAccrualStateError, + InvalidPositionAccrualTimestampError, + InvalidPositionLossFactorError, + InvalidTickSpacingError, + InvalidTreeError, + InvalidTreeHeightError, + MidnightApiError, + MidnightMempoolValidationError, + PriceGreaterThanOneError, + SettlementFeeExceedsPriceError, + TickOutOfRangeError, + UnknownCollateralIndexError, +} from "@morpho-org/midnight-sdk"; export type { ErrorClass } from "@morpho-org/morpho-ts"; export { _try } from "@morpho-org/morpho-ts"; diff --git a/packages/morpho-sdk/src/helpers/index.ts b/packages/morpho-sdk/src/helpers/index.ts index f95594a97..b6083f3be 100644 --- a/packages/morpho-sdk/src/helpers/index.ts +++ b/packages/morpho-sdk/src/helpers/index.ts @@ -9,6 +9,7 @@ export { MAX_TOKEN_APPROVALS, } from "./constant.js"; export { addTransactionMetadata } from "./metadata.js"; +export { signAndVerifyTypedData } from "./signAndVerifyTypedData.js"; export { computeMaxRepaySharePrice, computeMaxSupplySharePrice, @@ -30,3 +31,8 @@ export { validateWithdrawAmount, validateWithdrawShares, } from "./validate.js"; +export { validateOfferSides } from "./validateOfferSides.js"; +export { + type RequirementSpenderKey, + validateRequirementSpender, +} from "./validateRequirementSpender.js"; diff --git a/packages/morpho-sdk/src/helpers/signAndVerifyTypedData.ts b/packages/morpho-sdk/src/helpers/signAndVerifyTypedData.ts new file mode 100644 index 000000000..ba5441f8f --- /dev/null +++ b/packages/morpho-sdk/src/helpers/signAndVerifyTypedData.ts @@ -0,0 +1,55 @@ +import type { Address, Hex, TypedDataDefinition, WalletClient } from "viem"; +import { verifyTypedData } from "viem"; +import { signTypedData } from "viem/actions"; +import { InvalidSignatureError } from "../types/index.js"; +import { validateUserAddress } from "./validate.js"; + +/** + * Signs EIP-712 typed data with a wallet client, verifies that the produced signature recovers + * `userAddress`, and returns the signature. + * + * @param params - Signing and verification parameters. + * @param params.client - Wallet client used to sign the typed data. + * @param params.userAddress - Address expected to own the produced signature. + * @param params.typedData - EIP-712 typed data to sign and verify. + * @returns The verified EIP-712 signature. + * @throws {MissingClientPropertyError} when the wallet client has no account address. + * @throws {AddressMismatchError} when the wallet client account differs from `userAddress`. + * @throws {InvalidSignatureError} when the signature does not recover to `userAddress`. + * @example + * ```ts + * import { signAndVerifyTypedData } from "@morpho-org/morpho-sdk"; + * + * const signature = await signAndVerifyTypedData({ + * client: walletClient, + * userAddress, + * typedData, + * }); + * ``` + */ +export const signAndVerifyTypedData = async (params: { + readonly client: WalletClient; + readonly userAddress: Address; + readonly typedData: TypedDataDefinition, string>; +}): Promise => { + const { client, userAddress, typedData } = params; + const account = client.account; + validateUserAddress(account?.address, userAddress); + + const signature = await signTypedData(client, { + ...typedData, + account, + }); + + const isValid = await verifyTypedData({ + ...typedData, + address: userAddress, + signature, + }); + + if (!isValid) { + throw new InvalidSignatureError(); + } + + return signature; +}; diff --git a/packages/morpho-sdk/src/helpers/validateOfferSides.ts b/packages/morpho-sdk/src/helpers/validateOfferSides.ts new file mode 100644 index 000000000..fd0653142 --- /dev/null +++ b/packages/morpho-sdk/src/helpers/validateOfferSides.ts @@ -0,0 +1,31 @@ +import { MidnightOfferSideMismatchError } from "../types/index.js"; + +/** + * Validates that Midnight offers match a named flow's maker side. + * + * @param offers - Offers to validate. + * @param expectedBuy - Expected maker side for every offer. + * @throws {MidnightOfferSideMismatchError} when any offer side differs from `expectedBuy`. + * @example + * ```ts + * import { validateOfferSides } from "@morpho-org/morpho-sdk"; + * + * validateOfferSides(offers, true); + * ``` + */ +export const validateOfferSides = ( + offers: Iterable<{ readonly buy: boolean }>, + expectedBuy: boolean, +) => { + let index = 0; + for (const offer of offers) { + if (offer.buy !== expectedBuy) { + throw new MidnightOfferSideMismatchError({ + index, + expectedBuy, + actualBuy: offer.buy, + }); + } + index += 1; + } +}; diff --git a/packages/morpho-sdk/src/helpers/validateRequirementSpender.ts b/packages/morpho-sdk/src/helpers/validateRequirementSpender.ts new file mode 100644 index 000000000..b32943a98 --- /dev/null +++ b/packages/morpho-sdk/src/helpers/validateRequirementSpender.ts @@ -0,0 +1,66 @@ +import { type Address, getChainAddresses } from "@morpho-org/blue-sdk"; +import { isAddressEqual } from "viem"; +import { UnsupportedErc20ApprovalSpenderError } from "../types/index.js"; + +/** Supported spender slots that can be validated against the chain address registry. */ +export type RequirementSpenderKey = + | "generalAdapter1" + | "permit2" + | "midnight" + | "midnightBundles"; + +/** + * Validates that a requirement encoder spender matches one of the allowed chain addresses. + * + * @param params - Spender validation parameters. + * @param params.chainId - Chain id used to resolve supported spender addresses. + * @param params.spender - Spender address to validate. + * @param params.allowed - Allowed registry slots for this requirement. + * @throws {UnsupportedErc20ApprovalSpenderError} when `spender` does not match any allowed slot. + * @example + * ```ts + * import { validateRequirementSpender } from "@morpho-org/morpho-sdk"; + * + * validateRequirementSpender({ + * chainId: 1, + * spender: generalAdapter1, + * allowed: ["generalAdapter1"], + * }); + * ``` + */ +export const validateRequirementSpender = (params: { + readonly chainId: number; + readonly spender: Address; + readonly allowed: readonly RequirementSpenderKey[]; +}) => { + const { + permit2, + midnight, + midnightBundles, + bundler3: { generalAdapter1 }, + } = getChainAddresses(params.chainId); + const addresses = { + generalAdapter1, + permit2, + midnight, + midnightBundles, + } satisfies Record; + const supportedSpenders = params.allowed.map((key) => addresses[key]); + + if ( + !supportedSpenders.some( + (supported) => + supported != null && isAddressEqual(params.spender, supported), + ) + ) { + throw new UnsupportedErc20ApprovalSpenderError({ + spender: params.spender, + chainId: params.chainId, + generalAdapter1, + permit2, + midnight, + midnightBundles, + supportedSpenders, + }); + } +}; diff --git a/packages/morpho-sdk/src/midnight-api.ts b/packages/morpho-sdk/src/midnight-api.ts new file mode 100644 index 000000000..6e2b4fe1e --- /dev/null +++ b/packages/morpho-sdk/src/midnight-api.ts @@ -0,0 +1 @@ +export * from "@morpho-org/midnight-sdk/api"; diff --git a/packages/morpho-sdk/src/types/AGENTS.md b/packages/morpho-sdk/src/types/AGENTS.md index c2cf9fe21..8842c9720 100644 --- a/packages/morpho-sdk/src/types/AGENTS.md +++ b/packages/morpho-sdk/src/types/AGENTS.md @@ -6,12 +6,14 @@ Centralized type definitions and error classes. Barrel-exported via `index.ts`. - `BaseAction` — discriminated union base, keyed on `type`. - `Transaction` — immutable `{ to, value, data, action }`. Returned from every action; deep-frozen. -- `Requirement` / `RequirementSignature` — prerequisite signing flow for permit/permit2. +- `Requirement` / `RequirementSignature` — prerequisite signing flow for permit/permit2 and Midnight offer roots. +- `ActionOutput` — lazy entity output with `getRequirements()` plus synchronous `buildTx(...)`. - `Metadata` — optional `{ origin, timestamp? }` for calldata tracing. - `DepositAmountArgs` — union enforcing at least one of `amount` / `nativeAmount`. Reused for vault deposits, market collateral supply, and market loan-asset supply. - `AssetsOrSharesArgs` — discriminated union `{ assets } | { shares }`. Used by repay (borrow-side) and withdraw (supply-side). `RepayAmountArgs` is kept as a deprecated alias. - `MarketParams` — Morpho Blue market params (`loanToken`, `collateralToken`, `oracle`, `irm`, `lltv`). - `BlueAuthorizationAction` — used for `morpho.setAuthorization()` pre-requisite transactions. +- `Midnight*Action` — Midnight fixed-rate action metadata for bundled taker flows, direct collateral/credit flows, maker-offer submission, and maker prerequisite transactions. ## Shared liquidity (`sharedLiquidity.ts`) diff --git a/packages/morpho-sdk/src/types/action.ts b/packages/morpho-sdk/src/types/action.ts index 0eb19c53f..21418acdc 100644 --- a/packages/morpho-sdk/src/types/action.ts +++ b/packages/morpho-sdk/src/types/action.ts @@ -262,6 +262,154 @@ export interface BlueAuthorizationAction } > {} +/** Metadata for a Midnight authorization prerequisite transaction. */ +export interface MidnightAuthorizationAction + extends BaseAction< + "midnightAuthorization", + { + authorized: Address; + isAuthorized: boolean; + onBehalf: Address; + } + > {} + +/** Metadata for a SetterRatifier ratify-root prerequisite transaction. */ +export interface SetterRatifierRatifyRootAction + extends BaseAction< + "setterRatifierRatifyRoot", + { + maker: Address; + root: Hex; + isRootRatified: boolean; + } + > {} + +/** Metadata for a Midnight bundle that lends into fixed-rate offers. */ +export interface MidnightTakeLendAction + extends BaseAction< + "midnightTakeLend", + { + market: Hex; + assets: bigint; + minUnits: bigint; + taker: Address; + reduceOnly: boolean; + takeableOffers: number; + collateralWithdrawals: number; + collateralReceiver: Address; + referralFeePct: bigint; + referralFeeRecipient: Address; + maxContinuousFee: bigint; + deadline: bigint; + } + > {} + +/** Metadata for a Midnight bundle that borrows from fixed-rate offers. */ +export interface MidnightTakeBorrowAction + extends BaseAction< + "midnightTakeBorrow", + { + market: Hex; + loanAssets: bigint; + maxUnits: bigint; + taker: Address; + reduceOnly: boolean; + receiver: Address; + collateralSupplies: number; + takeableOffers: number; + referralFeePct: bigint; + referralFeeRecipient: Address; + maxContinuousFee: bigint; + deadline: bigint; + } + > {} + +/** Metadata for a Midnight bundle that supplies collateral and borrows from fixed-rate offers. */ +export interface MidnightSupplyCollateralTakeBorrowAction + extends BaseAction< + "midnightSupplyCollateralTakeBorrow", + { + market: Hex; + collateralAssets: bigint; + loanAssets: bigint; + maxUnits: bigint; + taker: Address; + reduceOnly: boolean; + receiver: Address; + collateralSupplies: number; + takeableOffers: number; + referralFeePct: bigint; + referralFeeRecipient: Address; + maxContinuousFee: bigint; + deadline: bigint; + } + > {} + +/** Metadata for a direct Midnight collateral-supply transaction. */ +export interface MidnightSupplyCollateralAction + extends BaseAction< + "midnightSupplyCollateral", + { + market: Hex; + collateralIndex: bigint; + assets: bigint; + onBehalf: Address; + } + > {} + +/** Metadata for a Midnight mempool payload submission. */ +export interface MempoolSubmitOffersAction + extends BaseAction< + "mempoolSubmitOffers", + { + groups: readonly Hex[]; + root: Hex; + maker: Address; + ratifier: Address; + ratifierType: "ecrecover" | "setter"; + offers: number; + } + > {} + +/** Metadata for a direct Midnight credit redemption transaction. */ +export interface MidnightRedeemAction + extends BaseAction< + "midnightRedeem", + { + market: Hex; + units: bigint; + onBehalf: Address; + receiver: Address; + } + > {} + +/** Metadata for a Midnight bundle that repays credit and/or withdraws collateral. */ +export interface MidnightRepayWithdrawCollateralAction + extends BaseAction< + "midnightRepayWithdrawCollateral", + { + market: Hex; + repayAssets: bigint; + collateralWithdrawals: number; + onBehalf: Address; + collateralReceiver: Address; + referralFeePct: bigint; + referralFeeRecipient: Address; + deadline: bigint; + } + > {} + +/** Metadata for a direct Midnight offer-cancellation transaction. */ +export interface MidnightCancelOfferAction + extends BaseAction< + "midnightCancelOffer", + { + group: Hex; + amount: bigint; + onBehalf: Address; + } + > {} + export type TransactionAction = | ERC20ApprovalAction | VaultV2DepositAction @@ -282,7 +430,17 @@ export type TransactionAction = | BlueWithdrawCollateralAction | BlueRepayWithdrawCollateralAction | BlueRefinanceAction - | BlueAuthorizationAction; + | BlueAuthorizationAction + | MidnightAuthorizationAction + | SetterRatifierRatifyRootAction + | MidnightTakeLendAction + | MidnightTakeBorrowAction + | MidnightSupplyCollateralTakeBorrowAction + | MidnightSupplyCollateralAction + | MempoolSubmitOffersAction + | MidnightRedeemAction + | MidnightRepayWithdrawCollateralAction + | MidnightCancelOfferAction; export interface Transaction { readonly to: Address; @@ -341,19 +499,22 @@ export interface AuthorizationSignatureArgs { signature: Hex; } -/** - * A signable approval / authorization requirement. `sign()` returns the matching - * {@link RequirementSignature}; `action` describes the requirement without signing. - * - * Generic over the signature it produces so permit encoders narrow to - * {@link PermitRequirementSignature} and the authorization encoder to - * {@link AuthorizationRequirementSignature}; the default keeps the broad union for mixed arrays. - */ -export interface Requirement< - TSignature extends RequirementSignature = RequirementSignature, -> { - sign: (client: WalletClient, userAddress: Address) => Promise; - action: TSignature["action"]; +/** Signed Permit2 SignatureTransfer payload returned by Midnight bundle token-pull requirements. */ +export interface Permit2TransferArgs { + owner: Address; + nonce: bigint; + asset: Address; + signature: Hex; + amount: bigint; + deadline: bigint; +} + +/** Signed and encoded Ecrecover offer-root payload used by Midnight maker flows. */ +export interface MidnightOfferRootSignatureArgs { + owner: Address; + root: Hex; + signature: Hex; + payload: Hex; } export interface PermitAction @@ -378,6 +539,40 @@ export interface AuthorizationAction { authorized: Address; isAuthorized: boolean; deadline: bigint } > {} +/** Metadata for a Permit2 SignatureTransfer request. */ +export interface Permit2TransferAction + extends BaseAction< + "permit2Transfer", + { spender: Address; amount: bigint; deadline: bigint } + > {} + +/** Metadata for a Midnight offer-root signature request. */ +export interface MidnightOfferRootSignatureAction + extends BaseAction< + "midnightOfferRootSignature", + { + root: Hex; + ratifier: Address; + offers: number; + } + > {} + +/** Action metadata supported by signature requirements. */ +export type SignatureRequirementAction = + | PermitAction + | Permit2Action + | AuthorizationAction + | Permit2TransferAction + | MidnightOfferRootSignatureAction; + +/** Argument payloads returned by signature requirements. */ +export type RequirementSignatureArgs = + | PermitArgs + | Permit2Args + | AuthorizationSignatureArgs + | Permit2TransferArgs + | MidnightOfferRootSignatureArgs; + /** A signed ERC-2612 permit or Permit2 approval requirement. */ export interface PermitRequirementSignature { args: PermitArgs | Permit2Args; @@ -390,19 +585,136 @@ export interface AuthorizationRequirementSignature { action: AuthorizationAction; } +/** A signed Midnight Permit2 SignatureTransfer requirement. */ +export interface Permit2TransferRequirementSignature { + args: Permit2TransferArgs; + action: Permit2TransferAction; +} + +/** A signed Midnight Ecrecover offer-root requirement. */ +export interface MidnightOfferRootSignature { + args: MidnightOfferRootSignatureArgs; + action: MidnightOfferRootSignatureAction; +} + /** * The deep-frozen output of `Requirement.sign()`. Discriminated on `action.type`: - * `"permit"` / `"permit2"` carry token-approval args, `"authorization"` carries the signed - * Morpho authorization. Narrow with {@link isPermitSignature} / {@link isAuthorizationSignature}. + * `"permit"` / `"permit2"` carry Bundler3 token-approval args, `"authorization"` carries the + * signed Morpho authorization, and Midnight adds `"permit2Transfer"` plus + * `"midnightOfferRootSignature"`. */ -export type RequirementSignature = - | PermitRequirementSignature - | AuthorizationRequirementSignature; +export type RequirementSignature< + TAction extends SignatureRequirementAction | undefined = undefined, + TArgs extends RequirementSignatureArgs | undefined = undefined, +> = TAction extends SignatureRequirementAction + ? TArgs extends RequirementSignatureArgs + ? { + args: TArgs; + action: TAction; + } + : never + : + | PermitRequirementSignature + | AuthorizationRequirementSignature + | Permit2TransferRequirementSignature + | MidnightOfferRootSignature; + +type RequirementResult< + TSignatureOrAction extends RequirementSignature | SignatureRequirementAction, + TArgs extends RequirementSignatureArgs | undefined, +> = TSignatureOrAction extends SignatureRequirementAction + ? RequirementSignature< + TSignatureOrAction, + Extract + > + : Extract; + +/** + * A signable approval / authorization requirement. `sign()` returns the matching + * {@link RequirementSignature}; `action` describes the requirement without signing. + * + * Generic over the signature it produces so permit encoders narrow to + * {@link PermitRequirementSignature} and the authorization encoder to + * {@link AuthorizationRequirementSignature}; the two-parameter form is kept for + * Midnight action requirements that are parameterized by action and args. + */ +export interface Requirement< + TSignatureOrAction extends + | RequirementSignature + | SignatureRequirementAction = RequirementSignature, + TArgs extends RequirementSignatureArgs | undefined = undefined, +> { + sign: ( + client: WalletClient, + userAddress: Address, + ) => Promise>; + action: RequirementResult["action"]; +} /** Bundler3 token signature requirement. */ export type Bundler3TokenSignatureRequirement = Requirement; +/** Midnight Ecrecover offer-root signature requirement. */ +export type MidnightOfferRootRequirement = Requirement< + MidnightOfferRootSignatureAction, + MidnightOfferRootSignatureArgs +>; + +/** Permit or Permit2 token signature requirement. */ +export type TokenSignatureRequirement = + | Bundler3TokenSignatureRequirement + | Requirement; + +/** Bundler3 token signature result. */ +export type Bundler3TokenRequirementSignature = PermitRequirementSignature; + +/** Permit or Permit2 token signature result. */ +export type TokenRequirementSignature = + | Bundler3TokenRequirementSignature + | Permit2TransferRequirementSignature; + +/** Any signature result returned by an action-output signature requirement. */ +export type AnyRequirementSignature = + | TokenRequirementSignature + | MidnightOfferRootSignature; + +/** Any signature requirement returned by an entity action output. */ +export type SignatureRequirement = + | TokenSignatureRequirement + | MidnightOfferRootRequirement + | Requirement; + +/** Call action metadata that can appear as an action prerequisite. */ +export type CallRequirementAction = + | ERC20ApprovalAction + | BlueAuthorizationAction + | MidnightAuthorizationAction + | SetterRatifierRatifyRootAction + | MidnightSupplyCollateralAction; + +/** Onchain call prerequisite returned by action-output `getRequirements()`. */ +export type CallRequirement = Readonly>; + +/** Onchain call or signature prerequisite returned by an entity action output. */ +export type ActionRequirement = CallRequirement | SignatureRequirement; + +/** Lazy entity result exposing prerequisite resolution and synchronous transaction building. */ +export interface ActionOutput< + TAction extends BaseAction = TransactionAction, + TSignatures = RequirementSignature, +> { + buildTx: (signatures?: TSignatures) => Readonly>; + getRequirements: (params?: { + /** + * Prefer the ERC-2612 simple-permit path when the SDK detects support. + * Leave unset or set to `false` to force the Permit2/classic approval fallback when + * a token is known to be incompatible despite passing the SDK's shallow nonce probe. + */ + readonly useSimplePermit?: boolean; + }) => Promise; +} + export function isRequirementApproval( requirement: unknown, ): requirement is Transaction { @@ -420,7 +732,7 @@ export function isRequirementApproval( ); } -/** Checks whether an action requirement is a Blue authorization transaction. */ +/** Checks whether an action requirement is a Blue authorization call. */ export function isRequirementBlueAuthorization( requirement: unknown, ): requirement is Transaction { @@ -441,12 +753,15 @@ export function isRequirementBlueAuthorization( export function isRequirementSignature< T extends RequirementSignature = RequirementSignature, >( - requirement: - | Transaction - | Transaction - | Requirement - | undefined, -): requirement is Requirement { + requirement: CallRequirement | Requirement | undefined, +): requirement is Requirement; +export function isRequirementSignature( + requirement: CallRequirement | Requirement | undefined, +): requirement is Requirement; +export function isRequirementSignature( + requirement: ActionRequirement | undefined, +): requirement is SignatureRequirement; +export function isRequirementSignature(requirement: unknown): boolean { return ( requirement !== undefined && typeof requirement === "object" && @@ -482,27 +797,57 @@ export function isAuthorizationSignature( return signature.action.type === "authorization"; } -/** The typed permit / authorization slots a bundled path consumes, split from a `buildTx` array. */ +/** + * Narrows a {@link RequirementSignature} to a Midnight Permit2 SignatureTransfer payload. + * + * @param signature - The signed requirement to test. + * @returns `true` when `signature.action.type` is `"permit2Transfer"`. + */ +export function isPermit2TransferSignature( + signature: RequirementSignature, +): signature is Permit2TransferRequirementSignature { + return signature.action.type === "permit2Transfer"; +} + +/** + * Narrows a {@link RequirementSignature} to a Midnight offer-root signature. + * + * @param signature - The signed requirement to test. + * @returns `true` when `signature.action.type` is `"midnightOfferRootSignature"`. + */ +export function isMidnightOfferRootSignature( + signature: RequirementSignature, +): signature is MidnightOfferRootSignature { + return signature.action.type === "midnightOfferRootSignature"; +} + +/** The typed requirement-signature slots a transaction builder consumes, split from a `buildTx` array. */ export interface SelectedRequirementSignatures { /** The single permit / Permit2 signature, when present. */ permit?: PermitRequirementSignature; /** The single Morpho authorization signature, when present. */ authorization?: AuthorizationRequirementSignature; + /** The single Midnight Permit2 SignatureTransfer payload, when present. */ + permit2Transfer?: Permit2TransferRequirementSignature; + /** The single Midnight offer-root signature, when present. */ + midnightOfferRoot?: MidnightOfferRootSignature; } /** - * Splits a `buildTx` signature array into its typed permit / authorization slots, rejecting + * Splits a `buildTx` signature array into its typed requirement-signature slots, rejecting * ambiguous or unexpected input so a path never silently consumes the wrong signature. * - * A bundled path consumes at most one permit and one authorization signature. Passing several of - * the same kind, or a kind the path does not consume, is rejected with a typed error rather than - * silently dropping the extras — the latter could otherwise leave a required authorization or - * permit unsigned (and the bundle reverting on-chain) or apply the wrong signature. + * A bundled path consumes at most one signature of each accepted kind. Passing several of the same + * kind, or a kind the path does not consume, is rejected with a typed error rather than silently + * dropping the extras — the latter could otherwise leave a required authorization or permit + * unsigned (and the bundle reverting on-chain) or apply the wrong signature. * * @param signatures - The signatures passed to `buildTx`. * @param accepts - Which signature kinds this operation consumes. * @param accepts.permit - Whether a permit / Permit2 signature is consumed. * @param accepts.authorization - Whether a Morpho authorization signature is consumed. + * @param accepts.permit2Transfer - Whether a Midnight Permit2 SignatureTransfer is consumed. + * @param accepts.midnightOfferRoot - Whether a Midnight offer-root signature is consumed. * @returns The single permit and/or authorization signature, when present. * @throws {AmbiguousRequirementSignaturesError} when more than one signature of an accepted kind is present. * @throws {UnexpectedRequirementSignatureError} when a signature of a kind the operation does not consume is present. @@ -518,17 +863,28 @@ export interface SelectedRequirementSignatures { */ export function selectRequirementSignatures( signatures: readonly RequirementSignature[] | undefined, - accepts: { permit?: boolean; authorization?: boolean }, + accepts: { + permit?: boolean; + authorization?: boolean; + permit2Transfer?: boolean; + midnightOfferRoot?: boolean; + }, ): SelectedRequirementSignatures { if (signatures == null) return {}; const permits = signatures.filter(isPermitSignature); const authorizations = signatures.filter(isAuthorizationSignature); + const permit2Transfers = signatures.filter(isPermit2TransferSignature); + const midnightOfferRoots = signatures.filter(isMidnightOfferRootSignature); if (!accepts.permit && permits.length > 0) throw new UnexpectedRequirementSignatureError("permit"); if (!accepts.authorization && authorizations.length > 0) throw new UnexpectedRequirementSignatureError("authorization"); + if (!accepts.permit2Transfer && permit2Transfers.length > 0) + throw new UnexpectedRequirementSignatureError("permit2Transfer"); + if (!accepts.midnightOfferRoot && midnightOfferRoots.length > 0) + throw new UnexpectedRequirementSignatureError("midnightOfferRootSignature"); if (permits.length > 1) throw new AmbiguousRequirementSignaturesError("permit", permits.length); if (authorizations.length > 1) @@ -536,6 +892,21 @@ export function selectRequirementSignatures( "authorization", authorizations.length, ); + if (permit2Transfers.length > 1) + throw new AmbiguousRequirementSignaturesError( + "permit2Transfer", + permit2Transfers.length, + ); + if (midnightOfferRoots.length > 1) + throw new AmbiguousRequirementSignaturesError( + "midnightOfferRootSignature", + midnightOfferRoots.length, + ); - return { permit: permits[0], authorization: authorizations[0] }; + return { + permit: permits[0], + authorization: authorizations[0], + permit2Transfer: permit2Transfers[0], + midnightOfferRoot: midnightOfferRoots[0], + }; } diff --git a/packages/morpho-sdk/src/types/client.ts b/packages/morpho-sdk/src/types/client.ts index e09f936a8..80605eda8 100644 --- a/packages/morpho-sdk/src/types/client.ts +++ b/packages/morpho-sdk/src/types/client.ts @@ -2,6 +2,7 @@ import type { MarketParams } from "@morpho-org/blue-sdk"; import type { Address, Client } from "viem"; import type { BlueActions, + MidnightActions, VaultV1Actions, VaultV2Actions, } from "../actions/index.js"; @@ -23,4 +24,5 @@ export interface MorphoClientType { vaultV1: (vault: Address, chainId: number) => VaultV1Actions; vaultV2: (vault: Address, chainId: number) => VaultV2Actions; blue: (marketParams: MarketParams, chainId: number) => BlueActions; + midnight: (chainId: number) => MidnightActions; } diff --git a/packages/morpho-sdk/src/types/error.ts b/packages/morpho-sdk/src/types/error.ts index cea5aedc3..262f287b4 100644 --- a/packages/morpho-sdk/src/types/error.ts +++ b/packages/morpho-sdk/src/types/error.ts @@ -73,27 +73,34 @@ export namespace BundlerErrors { } } +/** Requirement signature kind accepted by action-output transaction builders. */ +export type RequirementSignatureKind = + | "permit" + | "authorization" + | "permit2Transfer" + | "midnightOfferRootSignature"; + /** * Thrown when `buildTx` receives more than one requirement signature of the same kind. * - * A bundled path consumes at most one permit and one authorization signature; passing several of - * the same kind is ambiguous and would silently drop all but the first, so it is rejected instead. + * A bundled path consumes at most one signature per accepted kind; passing several of the same + * kind is ambiguous and would silently drop all but the first, so it is rejected instead. * * @example * ```ts * import { AmbiguousRequirementSignaturesError } from "@morpho-org/morpho-sdk"; * * if (error instanceof AmbiguousRequirementSignaturesError) { - * // Pass a single permit (and at most one authorization) signature to buildTx. + * // Pass a single signature per accepted kind to buildTx. * } * ``` */ export class AmbiguousRequirementSignaturesError extends Error { /** - * @param kind - The over-supplied signature kind (`"permit"` or `"authorization"`). + * @param kind - The over-supplied signature kind. * @param count - How many signatures of that kind were received. */ - constructor(kind: "permit" | "authorization", count: number) { + constructor(kind: RequirementSignatureKind, count: number) { super( `Expected at most one ${kind} signature but received ${count}. Pass a single ${kind} signature to buildTx.`, ); @@ -101,9 +108,8 @@ export class AmbiguousRequirementSignaturesError extends Error { } /** - * Thrown when `buildTx` receives a requirement signature of a kind the operation does not consume - * (for example an authorization signature on a plain supply path). Surfacing it prevents a signed - * authorization or permit from being silently ignored. + * Thrown when `buildTx` receives a requirement signature of a kind the operation does not consume. + * Surfacing it prevents a signed requirement from being silently ignored. * * @example * ```ts @@ -116,9 +122,9 @@ export class AmbiguousRequirementSignaturesError extends Error { */ export class UnexpectedRequirementSignatureError extends Error { /** - * @param kind - The unexpected signature kind (`"permit"` or `"authorization"`). + * @param kind - The unexpected signature kind. */ - constructor(kind: "permit" | "authorization") { + constructor(kind: RequirementSignatureKind) { super( `Received a ${kind} signature that this operation does not consume. Remove it from the buildTx signatures array.`, ); @@ -164,6 +170,13 @@ export class ChainIdMismatchError extends Error { } } +/** Thrown when a runtime crypto API is required but unavailable. */ +export class CryptoUnavailableError extends Error { + constructor(feature: string) { + super(`Crypto API is required for ${feature} but is unavailable.`); + } +} + /** Thrown when the viem client is missing a property the call requires (e.g. `account.address`). */ export class MissingClientPropertyError extends Error { constructor(property: string) { @@ -253,6 +266,24 @@ export class DepositAssetMismatchError extends Error { } } +/** Thrown when a deposit's owner differs from the owner the supplied permit / permit2 signature was issued for. */ +export class DepositOwnerMismatchError extends Error { + constructor(depositOwner: Address, signatureOwner: Address) { + super( + `Deposit owner "${depositOwner}" does not match requirement signature owner "${signatureOwner}"`, + ); + } +} + +/** Thrown when a deposit's spender differs from the spender the supplied permit / permit2 signature was issued for. */ +export class DepositSpenderMismatchError extends Error { + constructor(depositSpender: Address, signatureSpender: Address) { + super( + `Deposit spender "${depositSpender}" does not match requirement signature spender "${signatureSpender}"`, + ); + } +} + /** Thrown when a `permit2` requirement signature is missing the `expiration` field. */ export class Permit2ExpirationMissingError extends Error { constructor() { @@ -262,6 +293,15 @@ export class Permit2ExpirationMissingError extends Error { } } +/** Thrown when a Blue Permit2 allowance signature is passed to a Midnight bundle token pull. */ +export class MidnightPermit2TransferSignatureRequiredError extends Error { + constructor() { + super( + 'Midnight token pulls require a requirement signature with action.type === "permit2Transfer". Re-sign using the Midnight Permit2 transfer flow.', + ); + } +} + /** Thrown when a vault deposit uses `nativeAmount` but the vault asset is not the chain's wNative. */ export class NativeAmountOnNonWNativeVaultError extends Error { constructor(vaultAsset: Address, wNative: Address) { @@ -619,6 +659,177 @@ export class UnknownReallocationPositionError extends UnknownDataError { } } +/** Thrown when a Midnight amount that must be positive is zero or negative. */ +export class NonPositiveMidnightAmountError extends Error { + constructor(label: string, amount: bigint) { + super(`Midnight ${label} must be positive, got "${amount}".`); + } +} + +/** Thrown when a Midnight amount that must be non-negative is negative. */ +export class NegativeMidnightAmountError extends Error { + constructor(label: string, amount: bigint) { + super(`Midnight ${label} must not be negative, got "${amount}".`); + } +} + +/** Thrown when a Midnight flow needs at least one takeable offer. */ +export class EmptyMidnightTakeableOffersError extends Error { + constructor() { + super( + "Midnight takeable offers cannot be empty. Refresh the quote and try again.", + ); + } +} + +/** Thrown when a Midnight offer has the wrong maker side for the requested flow. */ +export class MidnightOfferSideMismatchError extends Error { + constructor(params: { + index: number; + expectedBuy: boolean; + actualBuy: boolean; + }) { + super( + `Midnight offer "${params.index}" has buy="${params.actualBuy}", expected "${params.expectedBuy}". Use the matching flow or rebuild the offer list.`, + ); + } +} + +/** Thrown when a Midnight maker offer targets a different chain than the action flow. */ +export class MidnightOfferMarketChainMismatchError extends Error { + constructor(params: { + readonly index: number; + readonly expectedChainId: number; + readonly actualChainId: bigint; + }) { + super( + `Midnight offer "${params.index}" targets chain "${params.actualChainId}", expected "${params.expectedChainId}". Rebuild the offer set for the selected chain.`, + ); + } +} + +/** Thrown when a Midnight maker offer targets a different Midnight contract than the action flow. */ +export class MidnightOfferMarketAddressMismatchError extends Error { + constructor(params: { + readonly index: number; + readonly expectedMidnight: Address; + readonly actualMidnight: Address; + }) { + super( + `Midnight offer "${params.index}" targets Midnight "${params.actualMidnight}", expected "${params.expectedMidnight}". Rebuild the offer set for the selected chain.`, + ); + } +} + +/** Thrown when a Midnight make-lend offer does not use the approved loan token. */ +export class MidnightOfferMarketLoanTokenMismatchError extends Error { + constructor(params: { + readonly index: number; + readonly expectedLoanToken: Address; + readonly actualLoanToken: Address; + }) { + super( + `Midnight offer "${params.index}" uses loan token "${params.actualLoanToken}", expected "${params.expectedLoanToken}". Rebuild the lend offer set for the approved loan token.`, + ); + } +} + +/** Thrown when a quoted Midnight takeable offer belongs to a different market than the requested flow. */ +export class MidnightTakeableOfferMarketMismatchError extends Error { + constructor(params: { + index: number; + expectedMarket: string; + actualMarket: string; + }) { + super( + `Midnight takeable offer "${params.index}" belongs to market "${params.actualMarket}", expected "${params.expectedMarket}". Refresh the quote and try again.`, + ); + } +} + +/** Thrown when a Midnight offer tree uses an unsupported ratifier address. */ +export class UnknownMidnightRatifierError extends Error { + constructor(params: { + ratifier: Address; + ecrecoverRatifier: Address; + setterRatifier: Address; + }) { + super( + `Midnight offer tree uses ratifier "${params.ratifier}", expected "${params.ecrecoverRatifier}" or "${params.setterRatifier}". Rebuild the tree with a supported ratifier.`, + ); + } +} + +/** Thrown when a Midnight Ecrecover maker flow builds the submit transaction before signing. */ +export class MissingMidnightOfferRootSignatureError extends Error { + constructor() { + super( + "Midnight offer root signature is missing. Sign the offer-root requirement before building the submit transaction.", + ); + } +} + +/** Thrown when a Midnight offer-root signature does not match the prepared tree root. */ +export class MidnightOfferRootMismatchError extends Error { + constructor(params: { expectedRoot: string; actualRoot: string }) { + super( + `Midnight offer root mismatch: expected "${params.expectedRoot}", got "${params.actualRoot}". Rebuild the flow and sign again.`, + ); + } +} + +/** Thrown when a Midnight offer-root signature was produced by another maker account. */ +export class MidnightOfferRootOwnerMismatchError extends Error { + constructor(params: { expectedOwner: Address; actualOwner: Address }) { + super( + `Midnight offer root owner mismatch: expected "${params.expectedOwner}", got "${params.actualOwner}". Rebuild the flow and sign again.`, + ); + } +} + +/** Thrown when a Midnight offer-root signature targets another ratifier. */ +export class MidnightOfferRootRatifierMismatchError extends Error { + constructor(params: { expectedRatifier: Address; actualRatifier: Address }) { + super( + `Midnight offer root ratifier mismatch: expected "${params.expectedRatifier}", got "${params.actualRatifier}". Rebuild the flow and sign again.`, + ); + } +} + +/** Thrown when a Midnight offer-root signature was produced for another offer count. */ +export class MidnightOfferRootOfferCountMismatchError extends Error { + constructor(params: { expectedOffers: number; actualOffers: number }) { + super( + `Midnight offer root offer-count mismatch: expected "${params.expectedOffers}", got "${params.actualOffers}". Rebuild the flow and sign again.`, + ); + } +} + +/** Thrown when a Midnight redeem flow finds no credit units for the user. */ +export class NoMidnightCreditToRedeemError extends Error { + constructor(market: string) { + super(`No Midnight credit is available to redeem for market "${market}".`); + } +} + +/** Thrown when a Midnight redeem amount exceeds the user's accrued credit. */ +export class MidnightRedeemExceedsCreditError extends Error { + constructor(params: { market: string; units: bigint; credit: bigint }) { + super( + `Midnight redeem amount exceeds accrued credit on market "${params.market}": units "${params.units}", credit "${params.credit}". Redeem less or refresh the position data.`, + ); + } +} + +/** Thrown when a Midnight redeem amount exceeds the market's currently withdrawable liquidity. */ +export class InsufficientMidnightWithdrawableLiquidityError extends Error { + constructor(params: { market: string; units: bigint; withdrawable: bigint }) { + super( + `Midnight withdrawable liquidity is insufficient on market "${params.market}": units "${params.units}", withdrawable "${params.withdrawable}". Try again later or redeem less.`, + ); + } +} + /** Thrown when a market borrow's `minSharePrice` slippage bound is negative. */ export class NonPositiveMinBorrowSharePriceError extends Error { constructor(market: string) { diff --git a/packages/morpho-sdk/src/utils.ts b/packages/morpho-sdk/src/utils.ts index 7bf7c1240..ebf11be94 100644 --- a/packages/morpho-sdk/src/utils.ts +++ b/packages/morpho-sdk/src/utils.ts @@ -20,6 +20,17 @@ export { safeParseNumber, safeParseUnits, } from "@morpho-org/blue-sdk-viem"; +export { + EcrecoverRatifierUtils, + GroupUtils, + OfferUtils, + Payload, + RatifierUtils, + SetterRatifierUtils, + TakeAmountsLib, + TickLib, + TreeUtils, +} from "@morpho-org/midnight-sdk"; export type { ArrayElementType, DeepPartial, diff --git a/packages/morpho-sdk/test/fixtures/midnight.ts b/packages/morpho-sdk/test/fixtures/midnight.ts new file mode 100644 index 000000000..c0d7a61d7 --- /dev/null +++ b/packages/morpho-sdk/test/fixtures/midnight.ts @@ -0,0 +1,124 @@ +import { + type IOffer, + MAX_CONTINUOUS_FEE, + MarketParams, + MarketUtils, + OfferUtils, +} from "@morpho-org/midnight-sdk"; +import type { MidnightApiTake } from "@morpho-org/midnight-sdk/api"; +import { registerCustomAddresses } from "@morpho-org/morpho-ts"; +import type { Address, Hex } from "viem"; +import { zeroAddress } from "viem"; + +export const midnightChainId = 30_001_337; +export const midnightLiquidationCursor = 250000000000000000n; + +export const midnightAddresses = { + morpho: "0x0000000000000000000000000000000000000001" as Address, + permit2: "0x0000000000000000000000000000000000002222" as Address, + bundler3: "0x0000000000000000000000000000000000000002" as Address, + generalAdapter1: "0x0000000000000000000000000000000000000003" as Address, + adaptiveCurveIrm: "0x0000000000000000000000000000000000000004" as Address, + midnight: "0x0000000000000000000000000000000000001000" as Address, + midnightBundles: "0x0000000000000000000000000000000000002000" as Address, + midnightMempool: "0x0000000000000000000000000000000000003000" as Address, + ecrecoverRatifier: "0x0000000000000000000000000000000000004000" as Address, + setterRatifier: "0x0000000000000000000000000000000000005000" as Address, + loanToken: "0x0000000000000000000000000000000000006000" as Address, + dai: "0x0000000000000000000000000000000000006100" as Address, + collateralToken: "0x0000000000000000000000000000000000007000" as Address, + oracle: "0x0000000000000000000000000000000000008000" as Address, + maker: "0x0000000000000000000000000000000000009000" as Address, + taker: "0x000000000000000000000000000000000000a000" as Address, +}; + +registerCustomAddresses({ + addresses: { + [midnightChainId]: { + morpho: midnightAddresses.morpho, + permit2: midnightAddresses.permit2, + bundler3: { + bundler3: midnightAddresses.bundler3, + generalAdapter1: midnightAddresses.generalAdapter1, + }, + adaptiveCurveIrm: midnightAddresses.adaptiveCurveIrm, + midnight: midnightAddresses.midnight, + midnightBundles: midnightAddresses.midnightBundles, + midnightMempool: midnightAddresses.midnightMempool, + ecrecoverRatifier: midnightAddresses.ecrecoverRatifier, + setterRatifier: midnightAddresses.setterRatifier, + dai: midnightAddresses.dai, + }, + }, +}); + +export const midnightMarket = new MarketParams({ + chainId: midnightChainId, + midnight: midnightAddresses.midnight, + loanToken: midnightAddresses.loanToken, + collateralParams: [ + { + token: midnightAddresses.collateralToken, + lltv: 770000000000000000n, + liquidationCursor: midnightLiquidationCursor, + oracle: midnightAddresses.oracle, + }, + ], + maturity: 2_000n, + rcfThreshold: 0n, + enterGate: zeroAddress, + liquidatorGate: zeroAddress, +}); + +export const midnightOtherMarket = new MarketParams({ + chainId: midnightChainId, + midnight: midnightAddresses.midnight, + loanToken: midnightAddresses.loanToken, + collateralParams: [ + { + token: midnightAddresses.collateralToken, + lltv: 770000000000000000n, + liquidationCursor: midnightLiquidationCursor, + oracle: midnightAddresses.oracle, + }, + ], + maturity: 2_001n, + rcfThreshold: 0n, + enterGate: zeroAddress, + liquidatorGate: zeroAddress, +}); + +export const midnightMarketId = MarketUtils.toId(midnightMarket); + +export const midnightBaseOffer = (overrides: Partial = {}): IOffer => ({ + market: overrides.market ?? midnightMarket, + buy: overrides.buy ?? false, + maker: overrides.maker ?? midnightAddresses.maker, + start: overrides.start ?? 0n, + expiry: overrides.expiry ?? 2_100n, + tick: overrides.tick ?? 5_000n, + group: overrides.group, + callback: overrides.callback ?? zeroAddress, + callbackData: overrides.callbackData ?? "0x", + receiverIfMakerIsSeller: + overrides.receiverIfMakerIsSeller ?? + (overrides.buy ? zeroAddress : midnightAddresses.maker), + ratifier: overrides.ratifier ?? midnightAddresses.ecrecoverRatifier, + reduceOnly: overrides.reduceOnly ?? false, + maxUnits: overrides.maxUnits ?? 100n, + maxAssets: overrides.maxAssets ?? 0n, + continuousFeeCap: overrides.continuousFeeCap ?? MAX_CONTINUOUS_FEE, +}); + +export const midnightApiTake = ( + overrides: Partial = {}, +): MidnightApiTake => { + const offer = OfferUtils.toStruct({ offer: midnightBaseOffer(overrides) }); + + return { + marketId: MarketUtils.toId(offer.market), + units: 100n, + offer, + ratifierData: "0x1234" as Hex, + }; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75880236f..762cc785e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -375,6 +375,9 @@ importers: '@morpho-org/blue-sdk-viem': specifier: workspace:^ version: link:../blue-sdk-viem + '@morpho-org/midnight-sdk': + specifier: workspace:^ + version: link:../midnight-sdk '@morpho-org/morpho-ts': specifier: workspace:^ version: link:../morpho-ts