@lifi/composer-sdk 0.2.0-staging.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +81 -0
  2. package/dist/authoring/handles.cjs.map +1 -1
  3. package/dist/authoring/handles.d.cts +13 -6
  4. package/dist/authoring/handles.d.ts +13 -6
  5. package/dist/authoring/handles.js.map +1 -1
  6. package/dist/client.cjs +38 -6
  7. package/dist/client.cjs.map +1 -1
  8. package/dist/client.d.cts +25 -1
  9. package/dist/client.d.ts +25 -1
  10. package/dist/client.js +41 -6
  11. package/dist/client.js.map +1 -1
  12. package/dist/errors.cjs +4 -1
  13. package/dist/errors.cjs.map +1 -1
  14. package/dist/errors.js +6 -1
  15. package/dist/errors.js.map +1 -1
  16. package/dist/generated/config.generated.cjs.map +1 -1
  17. package/dist/generated/config.generated.d.cts +82 -7
  18. package/dist/generated/config.generated.d.ts +82 -7
  19. package/dist/generated/materialisers.generated.cjs +2 -8
  20. package/dist/generated/materialisers.generated.cjs.map +1 -1
  21. package/dist/generated/materialisers.generated.d.cts +1 -1
  22. package/dist/generated/materialisers.generated.d.ts +1 -1
  23. package/dist/generated/materialisers.generated.js +1 -6
  24. package/dist/generated/materialisers.generated.js.map +1 -1
  25. package/dist/generated/operations.generated.cjs +56 -31
  26. package/dist/generated/operations.generated.cjs.map +1 -1
  27. package/dist/generated/operations.generated.d.cts +204 -94
  28. package/dist/generated/operations.generated.d.ts +204 -94
  29. package/dist/generated/operations.generated.js +56 -31
  30. package/dist/generated/operations.generated.js.map +1 -1
  31. package/dist/index.cjs +8 -0
  32. package/dist/index.cjs.map +1 -1
  33. package/dist/index.d.cts +4 -3
  34. package/dist/index.d.ts +4 -3
  35. package/dist/index.js +8 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/{materialisers.generated-BfWJ-g7t.d.ts → materialisers.generated-BMkBQgWZ.d.ts} +3 -8
  38. package/dist/{materialisers.generated-DaJRwiRe.d.cts → materialisers.generated-BWq5oePO.d.cts} +3 -8
  39. package/dist/responseSchemas.cjs +110 -0
  40. package/dist/responseSchemas.cjs.map +1 -0
  41. package/dist/responseSchemas.d.cts +36 -0
  42. package/dist/responseSchemas.d.ts +36 -0
  43. package/dist/responseSchemas.js +74 -0
  44. package/dist/responseSchemas.js.map +1 -0
  45. package/dist/run/simulate.cjs +41 -0
  46. package/dist/run/simulate.cjs.map +1 -0
  47. package/dist/run/simulate.d.cts +59 -0
  48. package/dist/run/simulate.d.ts +59 -0
  49. package/dist/run/simulate.js +17 -0
  50. package/dist/run/simulate.js.map +1 -0
  51. package/dist/sdk.cjs +6 -1
  52. package/dist/sdk.cjs.map +1 -1
  53. package/dist/sdk.d.cts +13 -1
  54. package/dist/sdk.d.ts +13 -1
  55. package/dist/sdk.js +6 -1
  56. package/dist/sdk.js.map +1 -1
  57. package/package.json +7 -5
  58. package/src/authoring/handles.ts +19 -10
  59. package/src/client.ts +86 -11
  60. package/src/errors.ts +8 -10
  61. package/src/examples/aaveRepay.ts +55 -16
  62. package/src/examples/aaveRepayWithATokens.ts +41 -30
  63. package/src/examples/aaveRounding.ts +42 -0
  64. package/src/examples/aaveSetEMode.ts +0 -1
  65. package/src/examples/config.ts +1 -1
  66. package/src/examples/emitCustomEvent.ts +75 -0
  67. package/src/examples/invariantChecks.ts +96 -0
  68. package/src/examples/run.ts +20 -18
  69. package/src/examples/simulateCompiledSwap.ts +76 -0
  70. package/src/examples/simulateRawTransaction.ts +39 -0
  71. package/src/examples/untypedOpWithTypedRef.ts +26 -20
  72. package/src/generated/config.generated.ts +229 -6
  73. package/src/generated/materialisers.generated.ts +0 -12
  74. package/src/generated/operations.generated.ts +330 -177
  75. package/src/index.ts +19 -0
  76. package/src/responseSchemas.ts +143 -0
  77. package/src/run/simulate.ts +78 -0
  78. package/src/sdk.ts +20 -1
  79. package/src/examples/staged/aaveBorrow.ts +0 -104
  80. package/src/examples/staged/aaveDebtRebalanceExactOut.ts +0 -301
  81. package/src/examples/staged/aaveHealthFactorExplode.ts +0 -70
  82. package/src/examples/staged/aaveToMorphoDebtMigration.ts +0 -190
  83. package/src/examples/staged/aaveToMorphoDebtMigrationWithSwap.ts +0 -221
  84. package/src/examples/staged/aaveToMorphoDebtMigrationWithSwapExactOut.ts +0 -302
  85. package/src/examples/staged/flashloanRepay.ts +0 -138
  86. package/src/examples/staged/morphoBlueBorrow.ts +0 -89
  87. package/src/examples/staged/morphoBlueRepay.ts +0 -91
  88. package/src/examples/staged/registry.ts +0 -78
@@ -0,0 +1,143 @@
1
+ import type { SimulateResult, SimulationRevert } from '@lifi/compose-spec';
2
+ import z from 'zod';
3
+
4
+ /**
5
+ * Zod-backed parsers for Compose API response bodies.
6
+ *
7
+ * Server responses are untrusted input crossing into the SDK, so the shapes the
8
+ * client reads back are validated here rather than asserted with `as` casts. The
9
+ * schemas are module-private (the package builds with `--isolatedDeclarations`,
10
+ * which forbids exporting un-annotated complex values); the public surface is
11
+ * the typed `parse*` helpers, whose explicit return types double as the contract.
12
+ *
13
+ * The simulate union is the one response the SDK fully owns and parses itself
14
+ * (it is un-enveloped, unlike `/compose`), so it gets a complete schema kept in
15
+ * lockstep with the canonical `SimulateResult` in `@lifi/compose-spec` by the
16
+ * compile-time assertion below — the same anchoring `@lifi/api-schemas` uses for
17
+ * its server-side simulate schema, so the two cannot silently drift.
18
+ */
19
+
20
+ const balanceEntrySchema = z.object({
21
+ token: z.string(),
22
+ owner: z.string(),
23
+ amount: z.string(),
24
+ });
25
+
26
+ const simulateOkSchema = z.object({
27
+ status: z.literal('ok'),
28
+ block: z.number(),
29
+ timestamp: z.number(),
30
+ balancesBefore: z.array(balanceEntrySchema),
31
+ balancesAfter: z.array(balanceEntrySchema),
32
+ deltas: z.array(balanceEntrySchema),
33
+ gasUsed: z.string(),
34
+ });
35
+
36
+ const simulateRevertDecodeResultSchema = z.object({
37
+ errorCandidates: z
38
+ .array(
39
+ z.object({
40
+ decodedErrorSignature: z.string(),
41
+ decodedParams: z.array(z.string()),
42
+ }),
43
+ )
44
+ .optional(),
45
+ error: z.string().optional(),
46
+ });
47
+
48
+ const simulateRevertSchema = z.object({
49
+ status: z.literal('revert'),
50
+ block: z.number(),
51
+ timestamp: z.number(),
52
+ revertReason: z.string().optional(),
53
+ code: z.number().optional(),
54
+ rawErrorBytes: z.string().optional(),
55
+ decodeResult: simulateRevertDecodeResultSchema.optional(),
56
+ });
57
+
58
+ const simulateSetupErrorSchema = z.object({
59
+ status: z.literal('error'),
60
+ message: z.string(),
61
+ });
62
+
63
+ const simulateResultSchema = z.discriminatedUnion('status', [
64
+ simulateOkSchema,
65
+ simulateRevertSchema,
66
+ simulateSetupErrorSchema,
67
+ ]);
68
+
69
+ // The type the schema actually parses out. Kept private (referencing it from an
70
+ // exported declaration would break `--isolatedDeclarations`) and pinned to the
71
+ // canonical `SimulateResult` inside `parseSimulateResult` below.
72
+ type SimulateResultWire = z.infer<typeof simulateResultSchema>;
73
+
74
+ /**
75
+ * Parses a `POST /simulate` body into a {@link SimulateResult}. Returns `null`
76
+ * when the body does not match the `ok`/`revert`/`error` union, letting the
77
+ * caller raise a transport-level error.
78
+ */
79
+ export const parseSimulateResult = (body: unknown): SimulateResult | null => {
80
+ const parsed = simulateResultSchema.safeParse(body);
81
+ if (!parsed.success) return null;
82
+ // Pin the schema to the canonical contract in both directions, so it cannot
83
+ // silently drift (mirroring the conformance assertion `@lifi/api-schemas` runs
84
+ // on its server-side simulate schema). Assigning the inferred wire value to a
85
+ // `SimulateResult` checks wire→spec; `satisfies SimulateResultWire` checks
86
+ // spec→wire. A missing or mistyped field on either side fails to compile.
87
+ const result: SimulateResult = parsed.data;
88
+ return result satisfies SimulateResultWire;
89
+ };
90
+
91
+ /**
92
+ * The validated members of an HTTP 206 partial compile envelope. `data` is
93
+ * verified to be an object but kept untyped — compose-spec owns its full shape
94
+ * (`ComposeCompilePartialData`) as a hand-authored type, so the caller narrows
95
+ * it. `error` is fully validated.
96
+ */
97
+ export interface CompilePartialEnvelope {
98
+ readonly data: Record<string, unknown>;
99
+ readonly error: { readonly kind: string; readonly message: string };
100
+ }
101
+
102
+ const compilePartialEnvelopeSchema = z.object({
103
+ data: z.record(z.string(), z.unknown()),
104
+ error: z.object({ kind: z.string(), message: z.string() }),
105
+ });
106
+
107
+ /** Parses an HTTP 206 partial compile envelope; `null` when it does not match. */
108
+ export const parseCompilePartialEnvelope = (
109
+ body: unknown,
110
+ ): CompilePartialEnvelope | null => {
111
+ const parsed = compilePartialEnvelopeSchema.safeParse(body);
112
+ return parsed.success ? parsed.data : null;
113
+ };
114
+
115
+ /** Error envelope returned on non-2xx responses. */
116
+ export interface ServerErrorBody {
117
+ readonly error?: {
118
+ readonly kind?: string;
119
+ readonly message?: string;
120
+ readonly path?: string;
121
+ readonly details?: SimulationRevert;
122
+ };
123
+ }
124
+
125
+ // `details` is passed through (`z.custom`) rather than parsed: the SDK does not
126
+ // own the `SimulationRevert` schema and forwards the field verbatim onto
127
+ // `ComposeError`.
128
+ const serverErrorBodySchema = z.object({
129
+ error: z
130
+ .object({
131
+ kind: z.string().optional(),
132
+ message: z.string().optional(),
133
+ path: z.string().optional(),
134
+ details: z.custom<SimulationRevert>().optional(),
135
+ })
136
+ .optional(),
137
+ });
138
+
139
+ /** Validates an already-parsed error envelope; `null` when it does not match. */
140
+ export const parseServerErrorBody = (json: unknown): ServerErrorBody | null => {
141
+ const parsed = serverErrorBodySchema.safeParse(json);
142
+ return parsed.success ? parsed.data : null;
143
+ };
@@ -0,0 +1,78 @@
1
+ import type {
2
+ ComposeCompileResult,
3
+ SimulateRequest,
4
+ SlotFinderRequirement,
5
+ TrackedBalance,
6
+ } from '@lifi/compose-spec';
7
+
8
+ /**
9
+ * Input for {@link buildSimulateRequest}: a compile result plus the fields a
10
+ * simulation needs that a compiled flow does not carry.
11
+ */
12
+ export interface BuildSimulateRequestInput {
13
+ /**
14
+ * A result from `builder.compile(...)` / `client.compile(...)`. Both
15
+ * `success` and `partial` results carry a `transactionRequest`, so either is
16
+ * valid input — the helper does not discriminate on `status`.
17
+ */
18
+ readonly result: ComposeCompileResult;
19
+ /** EVM chain id. A compile result does not carry the target chain. */
20
+ readonly chainId: number;
21
+ /**
22
+ * The transaction sender (`from`). A compile result does not carry the
23
+ * signer, so it must be supplied here.
24
+ */
25
+ readonly signer: string;
26
+ /**
27
+ * Balances to watch across the simulation. These cannot be inferred from a
28
+ * flow and must be supplied by the caller.
29
+ */
30
+ readonly trackedBalances: readonly TrackedBalance[];
31
+ /** Optional funding instructions applied before simulation. */
32
+ readonly requirements?: readonly SlotFinderRequirement[];
33
+ /** Optional block to pin the simulation to. Omitted ⇒ chain head. */
34
+ readonly block?: number;
35
+ /**
36
+ * Optional native value override. Defaults to the compiled
37
+ * `transactionRequest.value` when omitted.
38
+ */
39
+ readonly value?: bigint | string;
40
+ }
41
+
42
+ /**
43
+ * Assembles a {@link SimulateRequest} from a compile result and the
44
+ * caller-supplied chain, signer, and tracked balances. Pure: performs no
45
+ * network I/O, does not mutate its input, and returns a fresh object.
46
+ *
47
+ * Bridges the common case — simulate the transaction you just compiled —
48
+ * without coupling raw-transaction simulation to the flow builder. The
49
+ * `value` defaults to the compiled transaction's value unless overridden.
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * const compiled = await builder.compile({ inputs: { ... }, signer: '0x1111...' });
54
+ * const req = buildSimulateRequest({
55
+ * result: compiled,
56
+ * chainId: 1,
57
+ * signer: '0x1111111111111111111111111111111111111111',
58
+ * trackedBalances: [{ token: '0xA0b8...', owner: '0x1111...' }],
59
+ * });
60
+ * const sim = await sdk.client.simulate(req);
61
+ * ```
62
+ */
63
+ export const buildSimulateRequest = (
64
+ input: BuildSimulateRequestInput,
65
+ ): SimulateRequest => {
66
+ const { result, chainId, signer, trackedBalances, requirements, block } =
67
+ input;
68
+ return {
69
+ chainId,
70
+ from: signer,
71
+ to: result.transactionRequest.to,
72
+ data: result.transactionRequest.data,
73
+ value: input.value ?? result.transactionRequest.value,
74
+ block,
75
+ requirements,
76
+ trackedBalances,
77
+ };
78
+ };
package/src/sdk.ts CHANGED
@@ -2,6 +2,8 @@ import type {
2
2
  ComposeCompileRequest,
3
3
  ComposeCompileResult,
4
4
  InputSpec,
5
+ SimulateRequest,
6
+ SimulateResult,
5
7
  } from '@lifi/compose-spec';
6
8
 
7
9
  import {
@@ -117,6 +119,18 @@ export interface ComposeSdk {
117
119
  flow: TypedFlow<T>,
118
120
  run: ComposeRunInput<T>,
119
121
  ) => ComposeCompileRequest;
122
+ /**
123
+ * Simulates a raw, pre-encoded transaction. Thin pass-through to
124
+ * {@link ComposeClient.simulate}; see it for the `ok` / `revert` / `error`
125
+ * result discrimination and error behaviour. Pair with `buildSimulateRequest`
126
+ * to assemble the request from a compile result.
127
+ *
128
+ * @param request - The raw transaction plus funding `requirements` and
129
+ * `trackedBalances`.
130
+ * @returns A {@link SimulateResult} (`ok` / `revert` / `error`).
131
+ * @throws {@link ComposeError} on network, validation, or server errors.
132
+ */
133
+ readonly simulate: (request: SimulateRequest) => Promise<SimulateResult>;
120
134
  }
121
135
 
122
136
  /**
@@ -170,5 +184,10 @@ export const createComposeSdk = (options: ComposeSdkOptions): ComposeSdk => {
170
184
  return Object.assign(builder, { compile });
171
185
  };
172
186
 
173
- return { client: composeClient, flow, request };
187
+ return {
188
+ client: composeClient,
189
+ flow,
190
+ request,
191
+ simulate: composeClient.simulate,
192
+ };
174
193
  };
@@ -1,104 +0,0 @@
1
- import type { ComposeCompileRequest, Flow } from '@lifi/compose-spec';
2
-
3
- import { createComposeSdk, guards } from '../../index.js';
4
- import type { Address } from '../../types.js';
5
- import { BASE_URL } from '../config.js';
6
-
7
- // Aave v3 contracts on Ethereum mainnet.
8
- const AAVE_V3_POOL = '0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2';
9
- const USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
10
- const VDEBT_USDC = '0x72E95b8931767C79bA4EeE721354d6E99a61D004';
11
-
12
- // 1.05 WAD (1e18) — minimum acceptable Aave health factor after the borrow.
13
- // The on-chain VM reverts the run if the post-borrow HF drops below this.
14
- const MIN_HEALTH_FACTOR_WAD = '1050000000000000000';
15
-
16
- export interface AaveBorrowInput {
17
- readonly owner: Address;
18
- readonly recipient: Address;
19
- readonly amount: `${bigint}`;
20
- }
21
-
22
- /**
23
- * Borrow USDC against existing Aave v3 collateral and transfer it to a recipient,
24
- * with an on-chain health-factor guard that reverts if the post-borrow position
25
- * is too thin.
26
- *
27
- * The proxy address must already hold supplied collateral. The borrow op
28
- * exposes the borrowed amount as a tracked resource so downstream nodes can
29
- * spend it.
30
- *
31
- * Demonstrates:
32
- * - `aave.borrow` with no resource input — the borrowed asset materialises
33
- * as the `borrowed` resource output.
34
- * - Chaining the borrow output into a `core.transfer` to forward funds.
35
- * - The `debtBalance` handle reports the post-borrow variable-debt balance
36
- * for the proxy.
37
- * - `aave.getHealthFactor` + `core.numericInvariant` guard asserts that
38
- * `healthFactor >= MIN_HEALTH_FACTOR_WAD` after the borrow; the run reverts
39
- * on-chain if the threshold is violated.
40
- */
41
- export const buildAaveBorrow = ({
42
- owner,
43
- recipient,
44
- amount,
45
- }: AaveBorrowInput): {
46
- flow: Flow;
47
- request: ComposeCompileRequest;
48
- } => {
49
- const sdk = createComposeSdk({ baseUrl: BASE_URL });
50
-
51
- // The flow takes no resource inputs — Aave hands us the borrowed asset.
52
- const builder = sdk.flow(1, {
53
- name: 'aave-borrow-usdc',
54
- inputs: {
55
- recipient: 'address',
56
- },
57
- });
58
-
59
- const borrowOut = builder.aave.borrow('borrow', {
60
- bind: {},
61
- config: {
62
- pool: AAVE_V3_POOL,
63
- asset: USDC,
64
- variableDebtToken: VDEBT_USDC,
65
- amount,
66
- },
67
- });
68
-
69
- // Read the post-borrow Aave health factor and assert it stays above the
70
- // minimum threshold. The numericInvariant guard lowers to an on-chain
71
- // `AssertRawInvariant` IR1 instruction; the run reverts if HF < threshold.
72
- builder.aave.getHealthFactor('hf', {
73
- bind: {},
74
- config: { pool: AAVE_V3_POOL },
75
- guards: [
76
- guards.coreNumericInvariant({
77
- port: 'healthFactor',
78
- op: 'gte',
79
- threshold: MIN_HEALTH_FACTOR_WAD,
80
- }),
81
- ],
82
- });
83
-
84
- // Forward the freshly borrowed USDC to the recipient.
85
- builder.core.transfer('send-borrowed', {
86
- bind: {
87
- amount: borrowOut.borrowed,
88
- recipient: builder.inputs.recipient,
89
- },
90
- config: {},
91
- });
92
-
93
- const flow = builder.build();
94
-
95
- const request = sdk.request(flow, {
96
- signer: owner,
97
- inputs: {
98
- recipient,
99
- },
100
- sweepTo: builder.context.sender,
101
- });
102
-
103
- return { flow, request };
104
- };
@@ -1,301 +0,0 @@
1
- import type { ComposeCompileRequest, Flow } from '@lifi/compose-spec';
2
-
3
- import { createComposeSdk, materialisers, resources } from '../../index.js';
4
- import type { Address } from '../../types.js';
5
- import { BASE_URL } from '../config.js';
6
-
7
- // Base mainnet token addresses.
8
- const BASE_WETH = '0x4200000000000000000000000000000000000006';
9
- const BASE_USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
10
- // Circle EURC on Base (6 decimals).
11
- const BASE_EURC = '0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42';
12
-
13
- // Token decimals for the Paraswap BUY (raw-address requests require them).
14
- const EURC_DECIMALS = 6;
15
- const USDC_DECIMALS = 6;
16
-
17
- // Aave V3 on Base: pool + WETH aToken + variable-debt tokens for the old (USDC)
18
- // and new (EURC) debt assets. The variable-debt token only feeds aave.borrow
19
- // (to read the post-borrow debt balance); aave.repay infers the asset from its
20
- // bound input resource. Verified live against Pool.getReserveData on Base.
21
- const AAVE_V3_POOL_BASE = '0xA238Dd80C259a72e81d7e4664a9801593F98d1c5';
22
- const A_BAS_WETH = '0xD4a0e0b9149BCee3C920d2E00b5dE09138fd8bb7';
23
- const VDEBT_BAS_USDC = '0x59dca05b6c26dbd64b5381374aAaC5CD05644C28';
24
- const VDEBT_BAS_EURC = '0x03D01595769333174036832e18fA2f17C74f8161';
25
-
26
- // Aave V3 flashloan premium (FLASHLOAN_PREMIUM_TOTAL) = 5 bps of principal.
27
- // Mirrors the builtins package's `computeDefaultFee('aave-v3', amount)`,
28
- // inlined so the example stays free of compose-internals imports (the SDK must
29
- // not cross the manifest boundary into the builtins package). Override if the
30
- // pool's premium has changed on-chain.
31
- const AAVE_V3_FEE_BPS = 5n;
32
- const BPS_DENOMINATOR = 10_000n;
33
- const computeAaveV3Fee = (principal: bigint): bigint =>
34
- (principal * AAVE_V3_FEE_BPS) / BPS_DENOMINATOR;
35
-
36
- // Source-cap slippage on the EURC → USDC BUY, in basis points. This bounds how
37
- // much extra EURC Augustus may pull above the quoted source before refunding.
38
- // `newDebtBorrowAmount` must exceed the resulting `maxAmountIn`.
39
- const DEFAULT_MAX_SLIPPAGE_BPS = 100; // 1%
40
-
41
- export interface AaveDebtRebalanceExactOutInput {
42
- readonly owner: Address;
43
- /**
44
- * WETH collateral used to seed the position (in smallest units, 18 decimals).
45
- * In production the position already exists and this seed step is omitted —
46
- * the collateral is never touched by the rebalance itself.
47
- */
48
- readonly collateralAmount: `${bigint}`;
49
- /**
50
- * Outstanding USDC debt to rebalance away from (6 decimals). The flashloan is
51
- * sized to this; in production size it just above the live debt so the capped
52
- * repay clears it after accrued interest.
53
- */
54
- readonly debtAmount: `${bigint}`;
55
- /**
56
- * EURC to borrow as the *new* debt (6 decimals). Sized as a *generous upper
57
- * bound* — large enough that the exact-output BUY can always produce
58
- * `debtAmount + aaveV3Fee` USDC even after `maxSlippageBps` of source slippage
59
- * (i.e. `newDebtBorrowAmount > maxAmountIn`). The BUY pulls only what it needs;
60
- * the unspent EURC is repaid straight back into the freshly-minted EURC debt,
61
- * so the *net* new debt is the original USDC debt (in EURC terms) plus only
62
- * the slippage/fee actually consumed — NOT the full upper bound.
63
- * Note: EUR/USD is not 1:1 — size off a live BUY quote, e.g.
64
- * newDebtBorrowAmount ≈ ceil((debtAmount + aaveV3Fee) * eurcPerUsdc * (1 + slippage))
65
- */
66
- readonly newDebtBorrowAmount: `${bigint}`;
67
- /** Optional source-cap slippage on the BUY, in bps (default 100 = 1%). */
68
- readonly maxSlippageBps?: number;
69
- /** Optional integrator label for analytics / fee capture. */
70
- readonly partner?: string;
71
- /** Optional fee recipient; required to actually receive a partner fee. */
72
- readonly partnerAddress?: Address;
73
- /** Optional fixed partner fee in bps (e.g. '50' = 0.5%); needs partnerAddress. */
74
- readonly partnerFeeBps?: string;
75
- }
76
-
77
- /**
78
- * Atomically **rebalance** (debt-swap) an Aave V3 position on Base from USDC
79
- * debt into EURC debt — same protocol, same WETH collateral, only the debt
80
- * *token* changes — using a native **exact-output** (Paraswap/Velora BUY) swap.
81
- *
82
- * This is the single-protocol "debt switch" pattern productised by Aave's
83
- * `ParaSwapDebtSwapAdapter` and DeFi Saver's "Debt Switch": flashloan/borrow the
84
- * new debt slightly over a cap, exact-out-swap to produce exactly the old debt
85
- * owed, repay the old debt, then repay the leftover new asset back into the
86
- * freshly-created new debt so lasting debt = only what was consumed. Contrast
87
- * with `aaveToMorphoDebtMigrationWithSwapExactOut.ts`, which *migrates* the whole
88
- * position across protocols (Aave → Morpho) and therefore needs a second
89
- * flashloan to move the collateral; here the collateral never moves, so a single
90
- * flashloan of the old debt token suffices.
91
- *
92
- * Why rebalance USDC → EURC: at the time of writing EURC's Base Aave V3 variable
93
- * borrow APR (~3.87%) sits below USDC's (~4.28%), and a EUR-based borrower may
94
- * also want the liability denominated in EUR to match income/assets (removing
95
- * USD/EUR FX risk on the debt). The reward is the APR delta over the holding
96
- * period; the cost is the Aave flashloan premium plus the realised swap
97
- * slippage on the consumed notional only.
98
- *
99
- * Phase 1 — provision the starting position on Aave V3 (illustrative; omit in
100
- * production where the position already exists):
101
- * - User direct-deposits WETH.
102
- * - `lifi.zap` supplies the WETH to Aave (WETH → aBasWETH routing edge).
103
- * - `aave.borrow` mints the USDC debt being rebalanced; the borrowed USDC is
104
- * swept back to the signer as the user's existing loan proceeds.
105
- *
106
- * Phase 2 — rebalance USDC debt → EURC debt with one flashloan:
107
- * - Flashloan (Aave V3, USDC) bridges the old-debt payoff before the new debt
108
- * exists.
109
- * - `aave.repay(mode: 'max')` clears the USDC debt with the flashloan; Aave's
110
- * Pool clamps to the outstanding debt exactly, any cap residual is swept.
111
- * - `aave.borrow` mints a *capped* (generously over-sized) `newDebtBorrowAmount`
112
- * of EURC against the same, still-supplied WETH collateral.
113
- * - `paraswap.buy` buys *exactly* `debtAmount + aaveV3Fee` USDC with the borrowed
114
- * EURC, pulling only the EURC needed. `amountOut` is the exact figure that
115
- * settles the USDC flashloan leg; `unspentIn` is the refunded, unspent EURC.
116
- * - `lifi.flashloanRepay` settles the USDC leg from `buy.amountOut` (no
117
- * feeBuffer: `amountOut == principal + fee` by construction).
118
- * - `aave.repay(mode: 'max')` repays the unspent EURC (`buy.unspentIn`) straight
119
- * back into the freshly-minted EURC debt, unwinding the borrow buffer. Any
120
- * genuine over-repay surfaces through the repay op's `residual`, swept to the
121
- * signer.
122
- *
123
- * After execution the signer holds the same WETH collateral on Aave V3 with EURC
124
- * debt equal to the original USDC debt (in EUR terms) plus only the consumed
125
- * slippage/fee; the USDC debt is closed.
126
- */
127
- export const buildAaveDebtRebalanceExactOut = ({
128
- owner,
129
- collateralAmount,
130
- debtAmount,
131
- newDebtBorrowAmount,
132
- maxSlippageBps = DEFAULT_MAX_SLIPPAGE_BPS,
133
- partner,
134
- partnerAddress,
135
- partnerFeeBps,
136
- }: AaveDebtRebalanceExactOutInput): {
137
- flow: Flow;
138
- request: ComposeCompileRequest;
139
- } => {
140
- const sdk = createComposeSdk({ baseUrl: BASE_URL });
141
-
142
- // The USDC the BUY must produce: the flashloan principal plus the Aave V3
143
- // flashloan premium. This is exactly what settles the debt flashloan leg.
144
- const exactUsdcOut = (
145
- BigInt(debtAmount) + computeAaveV3Fee(BigInt(debtAmount))
146
- ).toString() as `${bigint}`;
147
-
148
- const builder = sdk.flow(8453, {
149
- name: 'aave-debt-rebalance-usdc-to-eurc-exact-out',
150
- inputs: {
151
- initialCollateral: resources.erc20(BASE_WETH, 8453),
152
- // Single flashloan: the old (USDC) debt token. No collateral flashloan is
153
- // needed because the WETH collateral never leaves Aave.
154
- debtFlashloan: resources.erc20(BASE_USDC, 8453),
155
- },
156
- });
157
-
158
- // --- Phase 1: provision the starting Aave V3 USDC borrow -----------------
159
-
160
- // WETH → aBasWETH via the aave routing edge family (Aave V3 supply). The
161
- // supplied collateral is never withdrawn — it backs the new EURC debt — so
162
- // the aBasWETH output is intentionally left unconsumed AND unswept: it is the
163
- // user's resulting collateral position and must stay on the (persistent,
164
- // per-user) proxy. This is why the flow does NOT set a blanket `sweepTo`
165
- // (which would try to transfer the aToken out and revert, since collateral
166
- // backing an open debt cannot be moved); instead we explicitly transfer only
167
- // the genuinely-loose tokens below.
168
- builder.lifi.zap('supply-collateral', {
169
- bind: { amountIn: builder.inputs.initialCollateral },
170
- config: { resourceOut: resources.erc20(A_BAS_WETH, 8453) },
171
- });
172
-
173
- // Borrow the USDC debt being rebalanced. The proceeds are the user's loan;
174
- // they remain as the proxy's residual USDC and are forwarded to the signer at
175
- // the end of the flow (see `payout-borrow-proceeds`).
176
- builder.aave.borrow('borrow-usdc-debt', {
177
- bind: {},
178
- config: {
179
- pool: AAVE_V3_POOL_BASE,
180
- asset: BASE_USDC,
181
- variableDebtToken: VDEBT_BAS_USDC,
182
- amount: debtAmount,
183
- },
184
- });
185
-
186
- // --- Phase 2: rebalance USDC debt → EURC debt ----------------------------
187
-
188
- // Settle the USDC debt with the flashloan. mode: 'max' makes Aave's Pool
189
- // clamp paybackAmount to the outstanding debt exactly; unspent flashloan
190
- // principal flows through the op's residual port (swept).
191
- builder.aave.repay('repay-usdc-debt', {
192
- bind: {
193
- assetIn: builder.inputs.debtFlashloan,
194
- onBehalfOf: builder.context.executionAddress,
195
- },
196
- config: { pool: AAVE_V3_POOL_BASE, mode: 'max' },
197
- });
198
-
199
- // Borrow a capped (generously over-sized) EURC amount against the same WETH
200
- // collateral, now that the USDC debt is cleared. The over-borrow is
201
- // intentional and is unwound below.
202
- const borrowEurc = builder.aave.borrow('borrow-eurc-debt', {
203
- bind: {},
204
- config: {
205
- pool: AAVE_V3_POOL_BASE,
206
- asset: BASE_EURC,
207
- variableDebtToken: VDEBT_BAS_EURC,
208
- amount: newDebtBorrowAmount,
209
- },
210
- });
211
-
212
- // Exact-output BUY: produce EXACTLY `exactUsdcOut` USDC from the borrowed
213
- // EURC, pulling only the EURC needed and refunding the rest to the proxy
214
- // (surfaced as `unspentIn`). `amountOut` is the exact figure that settles the
215
- // USDC flashloan leg.
216
- const buyUsdc = builder.paraswap.buy('buy-usdc-exact', {
217
- bind: { amountIn: borrowEurc.borrowed },
218
- config: {
219
- resourceOut: resources.erc20(BASE_USDC, 8453),
220
- exactAmountOut: exactUsdcOut,
221
- srcDecimals: EURC_DECIMALS,
222
- destDecimals: USDC_DECIMALS,
223
- maxSlippageBps,
224
- ...(partner !== undefined ? { partner } : {}),
225
- ...(partnerAddress !== undefined ? { partnerAddress } : {}),
226
- ...(partnerFeeBps !== undefined ? { partnerFeeBps } : {}),
227
- },
228
- });
229
-
230
- // Settle the USDC flashloan leg with the exact BUY output. No feeBuffer:
231
- // `amountOut == principal + fee` by construction.
232
- builder.lifi.flashloanRepay('repay-debt-flashloan', {
233
- bind: { funds: buyUsdc.amountOut },
234
- config: { leg: 'debtFlashloan' },
235
- });
236
-
237
- // Unwind the over-borrow: repay the proxy's FULL residual EURC back into the
238
- // freshly-minted EURC debt, so the new debt equals exactly the EURC the swap
239
- // consumed. We read the live EURC balance rather than binding `buy.unspentIn`:
240
- // unspentIn is only Augustus's refund (cap − used), but the proxy ALSO still
241
- // holds the headroom we over-borrowed above the swap cap (borrow − cap), which
242
- // Augustus never pulled. The live balance captures both, leaving zero idle.
243
- // mode: 'exact' pulls precisely this balance (Aave clamps to min(balance,
244
- // debt)); mode: 'max' would pass type(uint256).max and try to pull the whole
245
- // outstanding debt, far exceeding the residual on the proxy, and revert.
246
- const eurcResidual = builder.core.balanceOf('read-eurc-residual', {
247
- bind: {},
248
- config: { token: BASE_EURC },
249
- });
250
- builder.aave.repay('repay-eurc-remainder', {
251
- bind: {
252
- assetIn: eurcResidual.balance,
253
- onBehalfOf: builder.context.executionAddress,
254
- },
255
- config: { pool: AAVE_V3_POOL_BASE, mode: 'exact' },
256
- });
257
-
258
- // Forward the genuinely-loose token — the residual USDC loan proceeds — to the
259
- // signer. We read the proxy's LIVE USDC balance rather than the fixed borrowed
260
- // amount: Aave's `mode: 'max'` USDC-debt repay overshoots the borrow by up to
261
- // 1 wei (ray-math round-trip), so the proxy ends a wei short of `borrowed` —
262
- // transferring a fixed amount would revert "transfer amount exceeds balance".
263
- // The live balance always moves exactly what is there.
264
- //
265
- // We deliberately do NOT use a blanket `sweepTo`: the only other proxy-held
266
- // balance is the aBasWETH collateral position, which cannot be transferred
267
- // while the EURC debt is open (Aave's finalizeTransfer reverts). The position
268
- // therefore stays on the persistent per-user proxy; we move out only the USDC.
269
- const usdcResidual = builder.core.balanceOf('read-usdc-residual', {
270
- bind: {},
271
- config: { token: BASE_USDC },
272
- });
273
- builder.core.transfer('payout-borrow-proceeds', {
274
- bind: {
275
- amount: usdcResidual.balance,
276
- recipient: builder.context.sender,
277
- },
278
- config: {},
279
- });
280
-
281
- const flow = builder.build();
282
-
283
- const request = sdk.request(flow, {
284
- signer: owner,
285
- inputs: {
286
- initialCollateral: materialisers.directDeposit({
287
- amount: collateralAmount,
288
- }),
289
- debtFlashloan: materialisers.flashloan({
290
- providerKind: 'aave-v3',
291
- amount: debtAmount,
292
- }),
293
- },
294
- // No blanket `sweepTo`: the resulting aBasWETH collateral position stays on
295
- // the proxy (it backs the open EURC debt and cannot be moved). The loose
296
- // USDC proceeds are forwarded via the explicit `payout-borrow-proceeds`
297
- // transfer above.
298
- });
299
-
300
- return { flow, request };
301
- };