Skip to content

Commit d45cf58

Browse files
Foulks-Plbclaude
andcommitted
feat(morpho-sdk): add native wrapping to blueRepay
Allow funding part or all of a Blue repay's transferAmount by wrapping native into wNative via GeneralAdapter1, mirroring blueSupply / blueSupplyCollateral. Adds the optional nativeAmount arg, the BlueRepayAction.nativeAmount field, and the exported NativeAmountExceedsTransferAmountError. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9d469ed commit d45cf58

6 files changed

Lines changed: 271 additions & 21 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@morpho-org/morpho-sdk": minor
3+
---
4+
5+
Add native-wrapping support to `blueRepay`. The action now accepts an optional `args.nativeAmount` that funds part or all of `transferAmount` by wrapping native into wNative via `GeneralAdapter1.wrapNative()` (the remainder is pulled via the ERC-20 path), mirroring the native-wrapping flow already available on `blueSupply` / `blueSupplyCollateral`. Requires the market's loan token to be the chain's wNative. The new `BlueRepayAction.args.nativeAmount` field and the `NativeAmountExceedsTransferAmountError` class are exported as public API.

packages/morpho-sdk/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Transaction builders for VaultV1, VaultV2, and Blue. Subfolders carry the layer-
77
## Routing summary
88

99
- **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.
10-
- **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`).
10+
- **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` and `repay` support native wrapping when `loanToken === wNative` (for `repay`, `nativeAmount` funds part or all of `transferAmount`); loan-asset `withdraw` supports optional PublicAllocator reallocations to top up market liquidity (same mechanism as `borrow`).
1111
- **Bundle composition, native wrapping, and reallocation rules** are canonical in [`src/actions/AGENTS.md`](./src/actions/AGENTS.md).
1212

1313
## Tests

packages/morpho-sdk/src/actions/blue/repay.test.ts

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import { getChainAddresses } from "@morpho-org/blue-sdk";
1+
import { addressesRegistry, getChainAddresses } from "@morpho-org/blue-sdk";
22
import { parseUnits } from "viem";
33
import { mainnet } from "viem/chains";
44
import { afterEach, describe, expect, vi } from "vitest";
55
import { WethUsdsBlue } from "../../../test/fixtures/blue.js";
66
import { test } from "../../../test/setup.js";
77
import {
88
MutuallyExclusiveRepayAmountsError,
9+
NativeAmountExceedsTransferAmountError,
10+
NativeAmountOnNonWNativeAssetError,
11+
NegativeNativeAmountError,
912
NonPositiveRepayAmountError,
1013
NonPositiveRepayMaxSharePriceError,
1114
NonPositiveTransferAmountError,
@@ -24,6 +27,14 @@ describe("blueRepay unit tests", () => {
2427
bundler3: { bundler3 },
2528
} = getChainAddresses(mainnet.id);
2629

30+
const { wNative } = addressesRegistry[mainnet.id];
31+
32+
/** Market params with wNative as loan token — enables native wrapping tests. */
33+
const wNativeLoanMarketParams = {
34+
...WethUsdsBlue,
35+
loanToken: wNative,
36+
};
37+
2738
test("should create repay-by-assets transaction", async ({ client }) => {
2839
const assets = parseUnits("1000", 6);
2940

@@ -440,4 +451,161 @@ describe("blueRepay unit tests", () => {
440451
expect(txWith.data.includes("a1b2c3d4")).toBe(true);
441452
expect(txWith.action.type).toBe("blueRepay");
442453
});
454+
455+
test("should fully fund repay-by-assets via native wrapping", async ({
456+
client,
457+
}) => {
458+
const assets = parseUnits("1", 18);
459+
460+
const localSpy = vi.spyOn(
461+
getRequirementsActionModule,
462+
"getRequirementsAction",
463+
);
464+
465+
const tx = blueRepay({
466+
market: {
467+
chainId: mainnet.id,
468+
marketParams: wNativeLoanMarketParams,
469+
},
470+
args: {
471+
assets,
472+
shares: 0n,
473+
transferAmount: assets,
474+
nativeAmount: assets,
475+
onBehalf: client.account.address,
476+
receiver: client.account.address,
477+
maxSharePrice: 1n,
478+
},
479+
});
480+
481+
expect(tx.action.type).toBe("blueRepay");
482+
expect(tx.action.args.nativeAmount).toBe(assets);
483+
// tx.value is derived from the nativeTransfer call by encodeBundle.
484+
expect(tx.value).toBe(assets);
485+
expect(tx.to).toBe(bundler3);
486+
// Fully native funding — no ERC-20 transfer and no requirement actions.
487+
expect(localSpy).not.toHaveBeenCalled();
488+
});
489+
490+
test("should fund repay with mixed native and ERC-20 amounts", async ({
491+
client,
492+
}) => {
493+
const nativeAmount = parseUnits("0.4", 18);
494+
const transferAmount = parseUnits("1", 18);
495+
496+
const tx = blueRepay({
497+
market: {
498+
chainId: mainnet.id,
499+
marketParams: wNativeLoanMarketParams,
500+
},
501+
args: {
502+
assets: transferAmount,
503+
shares: 0n,
504+
transferAmount,
505+
nativeAmount,
506+
onBehalf: client.account.address,
507+
receiver: client.account.address,
508+
maxSharePrice: 1n,
509+
},
510+
});
511+
512+
expect(tx.action.args.nativeAmount).toBe(nativeAmount);
513+
// Only the native portion carries value; the remainder is pulled via ERC-20.
514+
expect(tx.value).toBe(nativeAmount);
515+
expect(tx.to).toBe(bundler3);
516+
});
517+
518+
test("should keep the shares-mode skim when funding via native", async ({
519+
client,
520+
}) => {
521+
const shares = parseUnits("500", 6);
522+
const transferAmount = parseUnits("0.6", 18);
523+
524+
const tx = blueRepay({
525+
market: {
526+
chainId: mainnet.id,
527+
marketParams: wNativeLoanMarketParams,
528+
},
529+
args: {
530+
assets: 0n,
531+
shares,
532+
transferAmount,
533+
nativeAmount: transferAmount,
534+
onBehalf: client.account.address,
535+
receiver: client.account.address,
536+
maxSharePrice: 1n,
537+
},
538+
});
539+
540+
expect(tx.value).toBe(transferAmount);
541+
// Residual (wNative) is still skimmed back to the receiver in shares mode.
542+
const maxUint256Hex = "f".repeat(64);
543+
expect(tx.data.toLowerCase()).toContain(maxUint256Hex);
544+
});
545+
546+
test("should throw NegativeNativeAmountError when nativeAmount is negative", async ({
547+
client,
548+
}) => {
549+
expect(() =>
550+
blueRepay({
551+
market: {
552+
chainId: mainnet.id,
553+
marketParams: wNativeLoanMarketParams,
554+
},
555+
args: {
556+
assets: parseUnits("1", 18),
557+
shares: 0n,
558+
transferAmount: parseUnits("1", 18),
559+
nativeAmount: -1n,
560+
onBehalf: client.account.address,
561+
receiver: client.account.address,
562+
maxSharePrice: 1n,
563+
},
564+
}),
565+
).toThrow(NegativeNativeAmountError);
566+
});
567+
568+
test("should throw NativeAmountExceedsTransferAmountError when nativeAmount > transferAmount", async ({
569+
client,
570+
}) => {
571+
expect(() =>
572+
blueRepay({
573+
market: {
574+
chainId: mainnet.id,
575+
marketParams: wNativeLoanMarketParams,
576+
},
577+
args: {
578+
assets: parseUnits("1", 18),
579+
shares: 0n,
580+
transferAmount: parseUnits("1", 18),
581+
nativeAmount: parseUnits("2", 18),
582+
onBehalf: client.account.address,
583+
receiver: client.account.address,
584+
maxSharePrice: 1n,
585+
},
586+
}),
587+
).toThrow(NativeAmountExceedsTransferAmountError);
588+
});
589+
590+
test("should throw NativeAmountOnNonWNativeAssetError when loan token is not wNative", async ({
591+
client,
592+
}) => {
593+
expect(() =>
594+
blueRepay({
595+
market: {
596+
chainId: mainnet.id,
597+
marketParams: WethUsdsBlue,
598+
},
599+
args: {
600+
assets: parseUnits("1000", 6),
601+
shares: 0n,
602+
transferAmount: parseUnits("1000", 6),
603+
nativeAmount: parseUnits("1000", 6),
604+
onBehalf: client.account.address,
605+
receiver: client.account.address,
606+
maxSharePrice: 1n,
607+
},
608+
}),
609+
).toThrow(NativeAmountOnNonWNativeAssetError);
610+
});
443611
});

packages/morpho-sdk/src/actions/blue/repay.ts

Lines changed: 78 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import { type Address, maxUint256 } from "viem";
44
import { type Action, BundlerAction } from "../../bundler/index.js";
55
import {
66
addTransactionMetadata,
7+
validateNativeAsset,
78
validateRepayParams,
89
} from "../../helpers/index.js";
9-
import type {
10-
BlueRepayAction,
11-
Metadata,
12-
RequirementSignature,
13-
Transaction,
10+
import {
11+
type BlueRepayAction,
12+
type Metadata,
13+
NativeAmountExceedsTransferAmountError,
14+
NegativeNativeAmountError,
15+
type RequirementSignature,
16+
type Transaction,
1417
} from "../../types/index.js";
1518
import { getRequirementsAction } from "../requirements/getRequirementsAction.js";
1619

@@ -37,6 +40,13 @@ export interface BlueRepayParams {
3740
receiver: Address;
3841
/** Maximum repay share price (in ray). Protects against share price manipulation. */
3942
maxSharePrice: bigint;
43+
/**
44+
* Optional portion of `transferAmount` to fund by wrapping native into wNative
45+
* (via `GeneralAdapter1.wrapNative()`) instead of pulling ERC-20. Must be
46+
* `<= transferAmount`; the remainder (`transferAmount - nativeAmount`) is pulled
47+
* via the ERC-20 path. Requires the loan token to be the chain's wNative.
48+
*/
49+
nativeAmount?: bigint;
4050
requirementSignature?: RequirementSignature;
4151
};
4252
metadata?: Metadata;
@@ -55,6 +65,11 @@ export interface BlueRepayParams {
5565
* Exactly one of `assets` / `shares` must be non-zero. Uses `maxSharePrice` to protect against
5666
* share price manipulation between transaction construction and execution.
5767
*
68+
* When `nativeAmount > 0`, that portion of `transferAmount` is funded by wrapping native ETH via
69+
* `GeneralAdapter1.wrapNative()` (the loan token must be the chain's wNative); any remaining
70+
* `transferAmount - nativeAmount` is pulled via the ERC-20 path. In shares mode the skimmed
71+
* residual is returned to `receiver` as wNative, not unwrapped native.
72+
*
5873
* @param params.market.chainId - The chain the market lives on.
5974
* @param params.market.marketParams - Market params (loanToken, collateralToken, oracle, irm, lltv).
6075
* @param params.args.assets - Repay amount in loan-token assets. Set to `0n` when repaying by shares.
@@ -67,8 +82,11 @@ export interface BlueRepayParams {
6782
* @param params.args.receiver - Address that receives residual loan tokens in shares mode.
6883
* @param params.args.maxSharePrice - Maximum acceptable repay share price (in ray). Slippage
6984
* protection.
85+
* @param params.args.nativeAmount - Optional portion of `transferAmount` to fund by wrapping
86+
* native into wNative. Must be `<= transferAmount`. Requires the loan token to be the chain's
87+
* wNative. The remainder is pulled via the ERC-20 path.
7088
* @param params.args.requirementSignature - Optional pre-signed permit/permit2 approval for the
71-
* loan-token transfer.
89+
* ERC-20 portion of the loan-token transfer.
7290
* @param params.metadata - Optional analytics metadata attached to the bundle.
7391
* @returns A deep-frozen `Transaction<BlueRepayAction>` with `to`, `value`, `data`, and the
7492
* typed `action` discriminator the simulation layer consumes.
@@ -78,6 +96,11 @@ export interface BlueRepayParams {
7896
* @throws {MutuallyExclusiveRepayAmountsError} when both `assets` and `shares` are non-zero.
7997
* @throws {NonPositiveTransferAmountError} when `transferAmount <= 0n`.
8098
* @throws {TransferAmountNotEqualToAssetsError} when in assets mode and `transferAmount !== assets`.
99+
* @throws {NegativeNativeAmountError} when `nativeAmount < 0n`.
100+
* @throws {NativeAmountExceedsTransferAmountError} when `nativeAmount > transferAmount`.
101+
* @throws {ChainWNativeMissingError} when `nativeAmount > 0n` but the chain has no configured wNative.
102+
* @throws {NativeAmountOnNonWNativeAssetError} when `nativeAmount > 0n` but the loan token is not
103+
* the chain's wNative.
81104
* @throws {DepositAssetMismatchError} from `getRequirementsAction` when `requirementSignature`
82105
* is provided and the signed asset differs from `marketParams.loanToken`.
83106
* @throws {DepositAmountMismatchError} from `getRequirementsAction` when `requirementSignature`
@@ -111,6 +134,7 @@ export const blueRepay = ({
111134
onBehalf,
112135
receiver,
113136
maxSharePrice,
137+
nativeAmount,
114138
requirementSignature,
115139
},
116140
metadata,
@@ -123,26 +147,60 @@ export const blueRepay = ({
123147
marketId: marketParams.id,
124148
});
125149

150+
if (nativeAmount !== undefined && nativeAmount < 0n) {
151+
throw new NegativeNativeAmountError(nativeAmount);
152+
}
153+
154+
const nativeFunding = nativeAmount ?? 0n;
155+
156+
if (nativeFunding > transferAmount) {
157+
throw new NativeAmountExceedsTransferAmountError(
158+
nativeFunding,
159+
transferAmount,
160+
);
161+
}
162+
126163
const {
127-
bundler3: { generalAdapter1 },
164+
bundler3: { generalAdapter1, bundler3 },
128165
} = getChainAddresses(chainId);
129166

130167
const actions: Action[] = [];
131168

132-
if (requirementSignature) {
169+
// Wrap the native portion of transferAmount into wNative inside GeneralAdapter1.
170+
if (nativeFunding > 0n) {
171+
validateNativeAsset(chainId, marketParams.loanToken);
172+
133173
actions.push(
134-
...getRequirementsAction({
135-
asset: marketParams.loanToken,
136-
amount: transferAmount,
137-
recipient: generalAdapter1,
138-
requirementSignature,
139-
}),
174+
{
175+
type: "nativeTransfer",
176+
args: [bundler3, generalAdapter1, nativeFunding, false],
177+
},
178+
{
179+
type: "wrapNative",
180+
args: [nativeFunding, generalAdapter1, false],
181+
},
140182
);
141-
} else {
142-
actions.push({
143-
type: "erc20TransferFrom",
144-
args: [marketParams.loanToken, transferAmount, generalAdapter1, false],
145-
});
183+
}
184+
185+
// Pull the remaining (non-native) portion of transferAmount via ERC-20.
186+
const erc20Funding = transferAmount - nativeFunding;
187+
188+
if (erc20Funding > 0n) {
189+
if (requirementSignature) {
190+
actions.push(
191+
...getRequirementsAction({
192+
asset: marketParams.loanToken,
193+
amount: erc20Funding,
194+
recipient: generalAdapter1,
195+
requirementSignature,
196+
}),
197+
);
198+
} else {
199+
actions.push({
200+
type: "erc20TransferFrom",
201+
args: [marketParams.loanToken, erc20Funding, generalAdapter1, false],
202+
});
203+
}
146204
}
147205

148206
actions.push({
@@ -184,6 +242,7 @@ export const blueRepay = ({
184242
onBehalf,
185243
receiver,
186244
maxSharePrice,
245+
nativeAmount,
187246
},
188247
},
189248
});

packages/morpho-sdk/src/types/action.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,11 @@ export interface BlueRepayAction
189189
onBehalf: Address;
190190
receiver: Address;
191191
maxSharePrice: bigint;
192+
/**
193+
* Portion of `transferAmount` funded by wrapping native into wNative.
194+
* `undefined` when the repay is fully funded by an ERC-20 transfer.
195+
*/
196+
nativeAmount?: bigint;
192197
}
193198
> {}
194199

0 commit comments

Comments
 (0)