@morpho-org/evm-simulation 1.0.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 (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -0
  3. package/lib/cjs/errors.d.ts +50 -0
  4. package/lib/cjs/errors.js +72 -0
  5. package/lib/cjs/index.d.ts +4 -0
  6. package/lib/cjs/index.js +17 -0
  7. package/lib/cjs/package.json +1 -0
  8. package/lib/cjs/screen-addresses/index.d.ts +1 -0
  9. package/lib/cjs/screen-addresses/index.js +5 -0
  10. package/lib/cjs/screen-addresses/sanctioned-addresses.d.ts +11 -0
  11. package/lib/cjs/screen-addresses/sanctioned-addresses.js +77 -0
  12. package/lib/cjs/screen-addresses/screen-addresses.d.ts +34 -0
  13. package/lib/cjs/screen-addresses/screen-addresses.js +152 -0
  14. package/lib/cjs/simulate/authorizations/index.d.ts +2 -0
  15. package/lib/cjs/simulate/authorizations/index.js +7 -0
  16. package/lib/cjs/simulate/authorizations/resolve.d.ts +9 -0
  17. package/lib/cjs/simulate/authorizations/resolve.js +29 -0
  18. package/lib/cjs/simulate/authorizations/validate.d.ts +6 -0
  19. package/lib/cjs/simulate/authorizations/validate.js +23 -0
  20. package/lib/cjs/simulate/backends/eth-simulate-v1.d.ts +15 -0
  21. package/lib/cjs/simulate/backends/eth-simulate-v1.js +76 -0
  22. package/lib/cjs/simulate/backends/index.d.ts +2 -0
  23. package/lib/cjs/simulate/backends/index.js +7 -0
  24. package/lib/cjs/simulate/backends/tenderly-rest.d.ts +20 -0
  25. package/lib/cjs/simulate/backends/tenderly-rest.js +269 -0
  26. package/lib/cjs/simulate/index.d.ts +1 -0
  27. package/lib/cjs/simulate/index.js +5 -0
  28. package/lib/cjs/simulate/parsing/index.d.ts +1 -0
  29. package/lib/cjs/simulate/parsing/index.js +5 -0
  30. package/lib/cjs/simulate/parsing/transfers.d.ts +27 -0
  31. package/lib/cjs/simulate/parsing/transfers.js +153 -0
  32. package/lib/cjs/simulate/pipeline/build-simulation-txs.d.ts +17 -0
  33. package/lib/cjs/simulate/pipeline/build-simulation-txs.js +50 -0
  34. package/lib/cjs/simulate/pipeline/bundler-retention.d.ts +19 -0
  35. package/lib/cjs/simulate/pipeline/bundler-retention.js +90 -0
  36. package/lib/cjs/simulate/pipeline/execute-simulation.d.ts +25 -0
  37. package/lib/cjs/simulate/pipeline/execute-simulation.js +87 -0
  38. package/lib/cjs/simulate/pipeline/index.d.ts +4 -0
  39. package/lib/cjs/simulate/pipeline/index.js +11 -0
  40. package/lib/cjs/simulate/pipeline/resolve-chain.d.ts +14 -0
  41. package/lib/cjs/simulate/pipeline/resolve-chain.js +19 -0
  42. package/lib/cjs/simulate/pipeline/validate-input.d.ts +10 -0
  43. package/lib/cjs/simulate/pipeline/validate-input.js +55 -0
  44. package/lib/cjs/simulate/simulate.d.ts +26 -0
  45. package/lib/cjs/simulate/simulate.js +46 -0
  46. package/lib/cjs/types.d.ts +131 -0
  47. package/lib/cjs/types.js +2 -0
  48. package/lib/esm/errors.d.ts +50 -0
  49. package/lib/esm/errors.js +62 -0
  50. package/lib/esm/index.d.ts +4 -0
  51. package/lib/esm/index.js +5 -0
  52. package/lib/esm/package.json +1 -0
  53. package/lib/esm/screen-addresses/index.d.ts +1 -0
  54. package/lib/esm/screen-addresses/index.js +1 -0
  55. package/lib/esm/screen-addresses/sanctioned-addresses.d.ts +11 -0
  56. package/lib/esm/screen-addresses/sanctioned-addresses.js +74 -0
  57. package/lib/esm/screen-addresses/screen-addresses.d.ts +34 -0
  58. package/lib/esm/screen-addresses/screen-addresses.js +149 -0
  59. package/lib/esm/simulate/authorizations/index.d.ts +2 -0
  60. package/lib/esm/simulate/authorizations/index.js +2 -0
  61. package/lib/esm/simulate/authorizations/resolve.d.ts +9 -0
  62. package/lib/esm/simulate/authorizations/resolve.js +26 -0
  63. package/lib/esm/simulate/authorizations/validate.d.ts +6 -0
  64. package/lib/esm/simulate/authorizations/validate.js +20 -0
  65. package/lib/esm/simulate/backends/eth-simulate-v1.d.ts +15 -0
  66. package/lib/esm/simulate/backends/eth-simulate-v1.js +73 -0
  67. package/lib/esm/simulate/backends/index.d.ts +2 -0
  68. package/lib/esm/simulate/backends/index.js +2 -0
  69. package/lib/esm/simulate/backends/tenderly-rest.d.ts +20 -0
  70. package/lib/esm/simulate/backends/tenderly-rest.js +266 -0
  71. package/lib/esm/simulate/index.d.ts +1 -0
  72. package/lib/esm/simulate/index.js +1 -0
  73. package/lib/esm/simulate/parsing/index.d.ts +1 -0
  74. package/lib/esm/simulate/parsing/index.js +1 -0
  75. package/lib/esm/simulate/parsing/transfers.d.ts +27 -0
  76. package/lib/esm/simulate/parsing/transfers.js +149 -0
  77. package/lib/esm/simulate/pipeline/build-simulation-txs.d.ts +17 -0
  78. package/lib/esm/simulate/pipeline/build-simulation-txs.js +47 -0
  79. package/lib/esm/simulate/pipeline/bundler-retention.d.ts +19 -0
  80. package/lib/esm/simulate/pipeline/bundler-retention.js +87 -0
  81. package/lib/esm/simulate/pipeline/execute-simulation.d.ts +25 -0
  82. package/lib/esm/simulate/pipeline/execute-simulation.js +84 -0
  83. package/lib/esm/simulate/pipeline/index.d.ts +4 -0
  84. package/lib/esm/simulate/pipeline/index.js +4 -0
  85. package/lib/esm/simulate/pipeline/resolve-chain.d.ts +14 -0
  86. package/lib/esm/simulate/pipeline/resolve-chain.js +16 -0
  87. package/lib/esm/simulate/pipeline/validate-input.d.ts +10 -0
  88. package/lib/esm/simulate/pipeline/validate-input.js +52 -0
  89. package/lib/esm/simulate/simulate.d.ts +26 -0
  90. package/lib/esm/simulate/simulate.js +43 -0
  91. package/lib/esm/types.d.ts +131 -0
  92. package/lib/esm/types.js +1 -0
  93. package/package.json +49 -0
  94. package/src/index.ts +27 -0
@@ -0,0 +1,266 @@
1
+ import { isAddress, isHex } from "viem";
2
+ import { z } from "zod";
3
+ import { ExternalServiceError, SimulationRevertedError } from "../../errors.js";
4
+ // Zod validators for the subset of Tenderly responses we consume.
5
+ // Addresses must be 40-hex-digit `0x...` (not just any string); topics and
6
+ // data must be `0x`-prefixed hex so downstream `getAddress()` / `BigInt()`
7
+ // calls don't throw inside `parseTransfers`, silently dropping transfers.
8
+ const addressSchema = z.custom((val) => typeof val === "string" && isAddress(val));
9
+ const hexSchema = z.custom((val) => typeof val === "string" && isHex(val));
10
+ const tenderlyRawResponseSchema = z
11
+ .object({
12
+ simulation: z
13
+ .object({
14
+ id: z.string().optional(),
15
+ status: z.boolean().optional(),
16
+ error_message: z.string().optional(),
17
+ })
18
+ .passthrough(),
19
+ transaction: z
20
+ .object({
21
+ error_message: z.string().optional(),
22
+ transaction_info: z
23
+ .object({
24
+ logs: z
25
+ .array(z
26
+ .object({
27
+ raw: z
28
+ .object({
29
+ address: addressSchema,
30
+ topics: z.array(hexSchema).optional(),
31
+ data: hexSchema.optional(),
32
+ })
33
+ .passthrough()
34
+ .optional(),
35
+ })
36
+ .passthrough())
37
+ .optional(),
38
+ asset_changes: z.unknown().optional(),
39
+ })
40
+ .passthrough(),
41
+ })
42
+ .passthrough(),
43
+ })
44
+ .passthrough();
45
+ const tenderlyBundleRawResponseSchema = z
46
+ .object({
47
+ simulation_results: z.array(tenderlyRawResponseSchema).min(1),
48
+ })
49
+ .passthrough();
50
+ /**
51
+ * Simulate transactions using Tenderly REST API.
52
+ *
53
+ * @param shareable - true persists the simulation in Tenderly and returns a shareable URL;
54
+ * false runs ephemerally with no URL. Translates to Tenderly's `save` wire field
55
+ * (`save_if_fails` is also set to `shareable` — so failed preview simulations are
56
+ * ALSO persisted and shareable. Callers that preview sensitive calldata should be
57
+ * aware this persists in Tenderly's dashboard regardless of success/failure).
58
+ */
59
+ export async function simulateTenderlyRest(params) {
60
+ const { config, chainId, transactions, blockNumber, shareable, signal, logger, } = params;
61
+ const baseUrl = buildBaseUrl(config);
62
+ try {
63
+ if (transactions.length === 1) {
64
+ return await simulateSingle({
65
+ baseUrl,
66
+ config,
67
+ chainId,
68
+ tx: transactions[0],
69
+ blockNumber,
70
+ shareable,
71
+ signal,
72
+ logger,
73
+ });
74
+ }
75
+ return await simulateBundle({
76
+ baseUrl,
77
+ config,
78
+ chainId,
79
+ transactions,
80
+ blockNumber,
81
+ shareable,
82
+ signal,
83
+ logger,
84
+ });
85
+ }
86
+ catch (error) {
87
+ if (error instanceof SimulationRevertedError)
88
+ throw error;
89
+ if (error instanceof ExternalServiceError)
90
+ throw error;
91
+ throw new ExternalServiceError(`Tenderly REST API error: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
92
+ }
93
+ }
94
+ /** URL-encode the path-segment config values so `/`, `?`, `#` can't retarget the request. */
95
+ function buildBaseUrl(config) {
96
+ const account = encodeURIComponent(config.accountSlug);
97
+ const project = encodeURIComponent(config.projectSlug);
98
+ return `${config.apiBaseUrl}/api/v1/account/${account}/project/${project}`;
99
+ }
100
+ /** Build the `TenderlySimulateRequest` body for one transaction. Shared by single + bundle. */
101
+ function buildTxBody(tx, chainId, shareable, blockNumber) {
102
+ return {
103
+ network_id: String(chainId),
104
+ from: tx.from,
105
+ to: tx.to,
106
+ input: tx.data,
107
+ value: String(tx.value ?? 0n),
108
+ save: shareable,
109
+ save_if_fails: shareable,
110
+ simulation_type: "full",
111
+ ...(blockNumber !== undefined && {
112
+ block_number: typeof blockNumber === "bigint" ? String(blockNumber) : blockNumber,
113
+ }),
114
+ };
115
+ }
116
+ async function simulateSingle(params) {
117
+ const { baseUrl, config, chainId, tx, blockNumber, shareable, signal, logger, } = params;
118
+ const body = buildTxBody(tx, chainId, shareable, blockNumber);
119
+ const response = await fetch(`${baseUrl}/simulate`, {
120
+ method: "POST",
121
+ headers: {
122
+ "Content-Type": "application/json",
123
+ "X-Access-Key": config.accessToken,
124
+ },
125
+ body: JSON.stringify(body),
126
+ signal,
127
+ });
128
+ if (!response.ok) {
129
+ throw new ExternalServiceError(`Tenderly REST API returned ${response.status}: ${response.statusText}`);
130
+ }
131
+ const data = tenderlyRawResponseSchema.parse(await response.json());
132
+ const result = parseTenderlyResponse(data, shareable);
133
+ if (shareable && data.simulation.id) {
134
+ const shared = await shareSimulation({
135
+ baseUrl,
136
+ config,
137
+ simulationId: data.simulation.id,
138
+ signal,
139
+ logger,
140
+ });
141
+ if (!shared)
142
+ result.tenderlyUrl = undefined;
143
+ }
144
+ return result;
145
+ }
146
+ async function simulateBundle(params) {
147
+ const { baseUrl, config, chainId, transactions, blockNumber, shareable, signal, logger, } = params;
148
+ const body = {
149
+ simulations: transactions.map((tx) => buildTxBody(tx, chainId, shareable, blockNumber)),
150
+ };
151
+ const response = await fetch(`${baseUrl}/simulate-bundle`, {
152
+ method: "POST",
153
+ headers: {
154
+ "Content-Type": "application/json",
155
+ "X-Access-Key": config.accessToken,
156
+ },
157
+ body: JSON.stringify(body),
158
+ signal,
159
+ });
160
+ if (!response.ok) {
161
+ throw new ExternalServiceError(`Tenderly REST API returned ${response.status}: ${response.statusText}`);
162
+ }
163
+ const data = tenderlyBundleRawResponseSchema.parse(await response.json());
164
+ const simulations = data.simulation_results;
165
+ // Collect logs from ALL steps (approvals + main tx) for complete bundler retention coverage.
166
+ // Only build the shareable URL from the last step; others parse with shareable=false.
167
+ const allLogs = [];
168
+ let lastResult;
169
+ let lastSimulationId;
170
+ for (let i = 0; i < simulations.length; i++) {
171
+ const sim = simulations[i];
172
+ const isLast = i === simulations.length - 1;
173
+ const parsed = parseTenderlyResponse(sim, isLast && shareable);
174
+ allLogs.push(...parsed.logs);
175
+ if (isLast) {
176
+ lastResult = parsed;
177
+ lastSimulationId = sim.simulation.id;
178
+ }
179
+ }
180
+ // Schema enforces `.min(1)` — lastResult is always defined after the loop.
181
+ let tenderlyUrl = lastResult.tenderlyUrl;
182
+ if (shareable && tenderlyUrl && lastSimulationId) {
183
+ const shared = await shareSimulation({
184
+ baseUrl,
185
+ config,
186
+ simulationId: lastSimulationId,
187
+ signal,
188
+ logger,
189
+ });
190
+ if (!shared)
191
+ tenderlyUrl = undefined;
192
+ }
193
+ return {
194
+ logs: allLogs,
195
+ tenderlyUrl,
196
+ rawAssetChanges: lastResult.rawAssetChanges,
197
+ };
198
+ }
199
+ /**
200
+ * Try to make a simulation publicly shareable. Returns true iff Tenderly's
201
+ * `/share` endpoint responded 2xx. On any other outcome (non-ok response,
202
+ * abort, network error), logs via `logger` and returns false — the caller
203
+ * then clears `tenderlyUrl`. `AbortError` is re-thrown so cancellation
204
+ * propagates cleanly instead of silently downgrading to "no URL".
205
+ */
206
+ async function shareSimulation(params) {
207
+ const { baseUrl, config, simulationId, signal, logger } = params;
208
+ const safeId = encodeURIComponent(simulationId);
209
+ try {
210
+ const response = await fetch(`${baseUrl}/simulations/${safeId}/share`, {
211
+ method: "POST",
212
+ headers: { "X-Access-Key": config.accessToken },
213
+ signal,
214
+ });
215
+ if (!response.ok) {
216
+ logger?.warn("Tenderly /share returned non-ok; tenderlyUrl will be cleared", {
217
+ simulationId,
218
+ status: response.status,
219
+ statusText: response.statusText,
220
+ });
221
+ return false;
222
+ }
223
+ return true;
224
+ }
225
+ catch (error) {
226
+ // Propagate cancellation; swallow only genuine fetch-layer failures.
227
+ if (error instanceof Error && error.name === "AbortError")
228
+ throw error;
229
+ logger?.warn("Tenderly /share fetch failed; tenderlyUrl will be cleared", {
230
+ simulationId,
231
+ error: error instanceof Error ? error.message : String(error),
232
+ });
233
+ return false;
234
+ }
235
+ }
236
+ function parseTenderlyResponse(data, shareable) {
237
+ const { simulation, transaction } = data;
238
+ if (simulation.status !== true) {
239
+ const errorMessage = transaction.error_message ||
240
+ simulation.error_message ||
241
+ "Transaction simulation reverted";
242
+ // NOTE: `data` includes the full Tenderly response (request body, calldata,
243
+ // asset changes). Callers that log `error.details` should redact — see
244
+ // `errors.ts` doc for `SimulationRevertedError.details`.
245
+ throw new SimulationRevertedError(errorMessage, data);
246
+ }
247
+ const rawLogs = [];
248
+ const logs = transaction.transaction_info.logs ?? [];
249
+ for (const log of logs) {
250
+ if (log.raw) {
251
+ rawLogs.push({
252
+ address: log.raw.address,
253
+ topics: log.raw.topics ?? [],
254
+ data: log.raw.data ?? "0x",
255
+ });
256
+ }
257
+ }
258
+ const tenderlyUrl = shareable && simulation.id
259
+ ? `https://dashboard.tenderly.co/shared/simulation/${simulation.id}`
260
+ : undefined;
261
+ return {
262
+ logs: rawLogs,
263
+ tenderlyUrl,
264
+ rawAssetChanges: transaction.transaction_info.asset_changes,
265
+ };
266
+ }
@@ -0,0 +1 @@
1
+ export { simulate } from "./simulate.js";
@@ -0,0 +1 @@
1
+ export { simulate } from "./simulate.js";
@@ -0,0 +1 @@
1
+ export { parseTransfers } from "./transfers.js";
@@ -0,0 +1 @@
1
+ export { parseTransfers } from "./transfers.js";
@@ -0,0 +1,27 @@
1
+ import type { RawLog, SimulationLogger, Transfer } from "../../types.js";
2
+ export declare const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
3
+ export declare const WITHDRAWAL_TOPIC = "0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65";
4
+ export declare const DEPOSIT_TOPIC = "0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c";
5
+ /**
6
+ * Parse raw EVM logs into individual Transfer events.
7
+ *
8
+ * **Supported event types:** ERC20 `Transfer(from, to, amount)` and WETH9
9
+ * `Deposit(to, amount)` / `Withdrawal(from, amount)`. WETH9 mint/burn Transfer
10
+ * events paired with their Deposit/Withdrawal are deduplicated. ERC721 and
11
+ * ERC1155 transfer events are **not** parsed — consumers with NFT flows will
12
+ * see an incomplete transfer list.
13
+ *
14
+ * **WETH9-specific dedup:** matches Transfer-to/from zero against the
15
+ * matching Deposit/Withdrawal event on the same contract with the same data
16
+ * and withdrawer. Non-standard wrapped-native implementations (e.g., rebasing
17
+ * or fee-on-withdraw wrappers) may escape the dedup and produce double-counts.
18
+ *
19
+ * **Failure mode:** on a per-log parse failure (malformed topic length,
20
+ * non-hex data), the log is skipped and a `warn` is emitted via the logger.
21
+ * The caller receives a possibly-incomplete transfer list. Upstream sources
22
+ * (e.g., Tenderly response schemas) should validate log shape before calling
23
+ * here to avoid silent drops.
24
+ *
25
+ * Output is sorted canonically by token, from, to, amount for determinism.
26
+ */
27
+ export declare function parseTransfers(logs: RawLog[], logger?: SimulationLogger): Transfer[];
@@ -0,0 +1,149 @@
1
+ import { getAddress, zeroAddress, zeroHash } from "viem";
2
+ // keccak256("Transfer(address,address,uint256)") — ERC-20 transfer event
3
+ export const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
4
+ // keccak256("Withdrawal(address,uint256)") — WETH9 unwrap event
5
+ export const WITHDRAWAL_TOPIC = "0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65";
6
+ // keccak256("Deposit(address,uint256)") — WETH9 wrap event
7
+ export const DEPOSIT_TOPIC = "0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c";
8
+ const TOPIC_HEX_LENGTH = 66; // "0x" + 32 bytes
9
+ const UINT256_HEX_LENGTH = 66; // "0x" + 32 bytes
10
+ /**
11
+ * Parse raw EVM logs into individual Transfer events.
12
+ *
13
+ * **Supported event types:** ERC20 `Transfer(from, to, amount)` and WETH9
14
+ * `Deposit(to, amount)` / `Withdrawal(from, amount)`. WETH9 mint/burn Transfer
15
+ * events paired with their Deposit/Withdrawal are deduplicated. ERC721 and
16
+ * ERC1155 transfer events are **not** parsed — consumers with NFT flows will
17
+ * see an incomplete transfer list.
18
+ *
19
+ * **WETH9-specific dedup:** matches Transfer-to/from zero against the
20
+ * matching Deposit/Withdrawal event on the same contract with the same data
21
+ * and withdrawer. Non-standard wrapped-native implementations (e.g., rebasing
22
+ * or fee-on-withdraw wrappers) may escape the dedup and produce double-counts.
23
+ *
24
+ * **Failure mode:** on a per-log parse failure (malformed topic length,
25
+ * non-hex data), the log is skipped and a `warn` is emitted via the logger.
26
+ * The caller receives a possibly-incomplete transfer list. Upstream sources
27
+ * (e.g., Tenderly response schemas) should validate log shape before calling
28
+ * here to avoid silent drops.
29
+ *
30
+ * Output is sorted canonically by token, from, to, amount for determinism.
31
+ */
32
+ export function parseTransfers(logs, logger) {
33
+ const transfers = [];
34
+ for (const log of logs) {
35
+ try {
36
+ const topic0 = log.topics[0];
37
+ if (topic0 === undefined)
38
+ continue;
39
+ switch (topic0) {
40
+ case WITHDRAWAL_TOPIC: {
41
+ const fromTopic = log.topics[1];
42
+ if (!isTopicHex(fromTopic) || !isUint256Hex(log.data)) {
43
+ warnMalformed(logger, log, "WETH9 Withdrawal: bad topic[1] or data length");
44
+ continue;
45
+ }
46
+ transfers.push({
47
+ token: getAddress(log.address),
48
+ from: getAddress(`0x${fromTopic.slice(26)}`),
49
+ to: zeroAddress,
50
+ amount: BigInt(log.data),
51
+ });
52
+ continue;
53
+ }
54
+ case DEPOSIT_TOPIC: {
55
+ const toTopic = log.topics[1];
56
+ if (!isTopicHex(toTopic) || !isUint256Hex(log.data)) {
57
+ warnMalformed(logger, log, "WETH9 Deposit: bad topic[1] or data length");
58
+ continue;
59
+ }
60
+ transfers.push({
61
+ token: getAddress(log.address),
62
+ from: zeroAddress,
63
+ to: getAddress(`0x${toTopic.slice(26)}`),
64
+ amount: BigInt(log.data),
65
+ });
66
+ continue;
67
+ }
68
+ case TRANSFER_TOPIC: {
69
+ // ERC-20 Transfer has 3 topics; ERC-721 Transfer has 4 (tokenId indexed).
70
+ // ERC-721 flows are out of scope.
71
+ if (log.topics.length !== 3)
72
+ continue;
73
+ const fromTopic = log.topics[1];
74
+ const toTopic = log.topics[2];
75
+ if (!isTopicHex(fromTopic) ||
76
+ !isTopicHex(toTopic) ||
77
+ !isUint256Hex(log.data)) {
78
+ warnMalformed(logger, log, "ERC20 Transfer: bad topic/data length");
79
+ continue;
80
+ }
81
+ // WETH9 unwrap dedup: Transfer to zero paired with a Withdrawal of equal amount.
82
+ if (toTopic === zeroHash &&
83
+ logs.some((other) => other.topics[0] === WITHDRAWAL_TOPIC &&
84
+ other.address === log.address &&
85
+ other.data === log.data &&
86
+ other.topics.length === 2 &&
87
+ other.topics[1] === fromTopic))
88
+ continue;
89
+ // WETH9 wrap dedup: Transfer from zero paired with a Deposit of equal amount.
90
+ if (fromTopic === zeroHash &&
91
+ logs.some((other) => other.topics[0] === DEPOSIT_TOPIC &&
92
+ other.address === log.address &&
93
+ other.data === log.data &&
94
+ other.topics.length === 2 &&
95
+ other.topics[1] === toTopic))
96
+ continue;
97
+ transfers.push({
98
+ token: getAddress(log.address),
99
+ from: getAddress(`0x${fromTopic.slice(26)}`),
100
+ to: getAddress(`0x${toTopic.slice(26)}`),
101
+ amount: BigInt(log.data),
102
+ });
103
+ continue;
104
+ }
105
+ }
106
+ }
107
+ catch (error) {
108
+ // Safety net for anything that slips past the length checks above
109
+ // (e.g., viem checksum throws on non-hex input). Loud enough to diagnose.
110
+ warnMalformed(logger, log, error instanceof Error ? error.message : String(error));
111
+ }
112
+ }
113
+ return sortTransfers(transfers);
114
+ }
115
+ function isTopicHex(value) {
116
+ return typeof value === "string" && value.length === TOPIC_HEX_LENGTH;
117
+ }
118
+ function isUint256Hex(value) {
119
+ return typeof value === "string" && value.length === UINT256_HEX_LENGTH;
120
+ }
121
+ function warnMalformed(logger, log, reason) {
122
+ logger?.warn("Skipping malformed log during transfer parsing", {
123
+ address: log.address,
124
+ topics: log.topics,
125
+ reason,
126
+ });
127
+ }
128
+ /**
129
+ * Canonical sort: by lowercased token, from, to, then amount. Addresses are
130
+ * pre-lowercased once to avoid redundant `.toLowerCase()` calls per comparison.
131
+ */
132
+ function sortTransfers(transfers) {
133
+ const decorated = transfers.map((t) => ({
134
+ t,
135
+ tokenLc: t.token.toLowerCase(),
136
+ fromLc: t.from.toLowerCase(),
137
+ toLc: t.to.toLowerCase(),
138
+ }));
139
+ decorated.sort((a, b) => {
140
+ if (a.tokenLc !== b.tokenLc)
141
+ return a.tokenLc < b.tokenLc ? -1 : 1;
142
+ if (a.fromLc !== b.fromLc)
143
+ return a.fromLc < b.fromLc ? -1 : 1;
144
+ if (a.toLc !== b.toLc)
145
+ return a.toLc < b.toLc ? -1 : 1;
146
+ return a.t.amount < b.t.amount ? -1 : a.t.amount > b.t.amount ? 1 : 0;
147
+ });
148
+ return decorated.map((d) => d.t);
149
+ }
@@ -0,0 +1,17 @@
1
+ import type { SimulateParams, SimulationTransaction } from "../../types.js";
2
+ /**
3
+ * Stage 3 of the simulate() pipeline.
4
+ *
5
+ * Prepends any resolved authorizations (e.g. ERC20 `approve` calls generated from
6
+ * `{ type: 'signature' }` entries) to the user's transactions. If no authorizations
7
+ * were supplied, returns the user's transactions unchanged.
8
+ *
9
+ * Defense-in-depth: after prepending, verifies EVERY resolved tx shares the user
10
+ * sender. This catches an `{ type: 'approval', transaction }` auth where the
11
+ * caller-provided `transaction.from` does not match the bundle sender — the
12
+ * `approval` variant otherwise passes through without validation. Rejecting here
13
+ * keeps the "all simulationTxs share one `from`" invariant the downstream
14
+ * backends (eth_simulateV1) assume, and prevents a malicious or buggy caller
15
+ * from prepending a tx on behalf of an unexpected address.
16
+ */
17
+ export declare function buildSimulationTxs(params: SimulateParams): SimulationTransaction[];
@@ -0,0 +1,47 @@
1
+ import { getAddress } from "viem";
2
+ import { SimulationValidationError } from "../../errors.js";
3
+ import { resolveAuthorizations } from "../authorizations/index.js";
4
+ /**
5
+ * Stage 3 of the simulate() pipeline.
6
+ *
7
+ * Prepends any resolved authorizations (e.g. ERC20 `approve` calls generated from
8
+ * `{ type: 'signature' }` entries) to the user's transactions. If no authorizations
9
+ * were supplied, returns the user's transactions unchanged.
10
+ *
11
+ * Defense-in-depth: after prepending, verifies EVERY resolved tx shares the user
12
+ * sender. This catches an `{ type: 'approval', transaction }` auth where the
13
+ * caller-provided `transaction.from` does not match the bundle sender — the
14
+ * `approval` variant otherwise passes through without validation. Rejecting here
15
+ * keeps the "all simulationTxs share one `from`" invariant the downstream
16
+ * backends (eth_simulateV1) assume, and prevents a malicious or buggy caller
17
+ * from prepending a tx on behalf of an unexpected address.
18
+ */
19
+ export function buildSimulationTxs(params) {
20
+ const { transactions, authorizations } = params;
21
+ if (!authorizations || authorizations.length === 0) {
22
+ return transactions;
23
+ }
24
+ const sender = transactions[0].from;
25
+ const resolved = resolveAuthorizations(authorizations, sender);
26
+ const simulationTxs = [...resolved, ...transactions];
27
+ const senderChecksum = getAddress(sender);
28
+ const mismatches = [];
29
+ for (let i = 0; i < simulationTxs.length; i++) {
30
+ const tx = simulationTxs[i];
31
+ let txFromChecksum;
32
+ try {
33
+ txFromChecksum = getAddress(tx.from);
34
+ }
35
+ catch {
36
+ mismatches.push(`simulationTxs[${i}].from: not a valid address (${tx.from})`);
37
+ continue;
38
+ }
39
+ if (txFromChecksum !== senderChecksum) {
40
+ mismatches.push(`simulationTxs[${i}].from: expected ${senderChecksum}, got ${txFromChecksum}`);
41
+ }
42
+ }
43
+ if (mismatches.length > 0) {
44
+ throw new SimulationValidationError("Authorization transaction(s) must share the user sender", mismatches);
45
+ }
46
+ return simulationTxs;
47
+ }
@@ -0,0 +1,19 @@
1
+ import type { SimulationLogger, Transfer } from "../../types.js";
2
+ interface AssertNoBundlerRetentionParams {
3
+ chainId: number;
4
+ transfers: Transfer[];
5
+ logger?: SimulationLogger;
6
+ }
7
+ /**
8
+ * Assert that no value is retained by bundler3 contract addresses.
9
+ *
10
+ * Uses net flow (inbound minus outbound) per (bundler address, token) pair.
11
+ * Bundler3 legitimately receives tokens as an intermediary (user → bundler3 →
12
+ * vault), so gross inbound would fire false positives. We only flag when
13
+ * `|net| > DUST_THRESHOLD` — catching both **retention** (tokens stuck in
14
+ * bundler, positive net) and **drain** (bundler sending more than it received
15
+ * in this bundle, negative net — which implies a pre-existing or state-override
16
+ * balance is being drawn down, a red flag for pre-broadcast integrity).
17
+ */
18
+ export declare function assertNoBundlerRetention(params: AssertNoBundlerRetentionParams): void;
19
+ export {};
@@ -0,0 +1,87 @@
1
+ import { UnsupportedChainIdError, getChainAddresses, } from "@morpho-org/blue-sdk";
2
+ import { isDefined } from "@morpho-org/morpho-ts";
3
+ import { getAddress } from "viem";
4
+ import { BlacklistViolationError } from "../../errors.js";
5
+ /**
6
+ * Dust tolerance for bundler retention, in raw token units.
7
+ *
8
+ * 100 units is negligible for 18-decimal tokens (~1e-16). For low-decimal
9
+ * tokens (e.g., 2-decimal stablecoins) this represents ~1.00 token — worth
10
+ * keeping in mind. The threshold is intentionally token-agnostic: its purpose
11
+ * is to absorb rounding noise from bundler3 routing, not to define an
12
+ * acceptable retention amount.
13
+ */
14
+ const DUST_THRESHOLD = 100n;
15
+ function getBundlerAddresses(chainId, logger) {
16
+ try {
17
+ const addresses = getChainAddresses(chainId);
18
+ if (!addresses.bundler3) {
19
+ // blue-sdk knows the chain but didn't catalog a bundler3 for it.
20
+ // Treat the same as UnsupportedChainIdError — retention check skipped.
21
+ logger?.warn("Chain known to blue-sdk but has no bundler3 config, retention check skipped", {
22
+ chainId,
23
+ });
24
+ return new Set();
25
+ }
26
+ return new Set(Object.values(addresses.bundler3)
27
+ .filter(isDefined)
28
+ .map((addr) => getAddress(addr)));
29
+ }
30
+ catch (error) {
31
+ if (error instanceof UnsupportedChainIdError) {
32
+ // Loud warn: this disables a "never bypassable" check for the chain.
33
+ // Consumers relying on the guarantee must handle this signal.
34
+ logger?.warn("Chain not supported by blue-sdk, retention check skipped", {
35
+ chainId,
36
+ });
37
+ return new Set();
38
+ }
39
+ throw error;
40
+ }
41
+ }
42
+ /**
43
+ * Assert that no value is retained by bundler3 contract addresses.
44
+ *
45
+ * Uses net flow (inbound minus outbound) per (bundler address, token) pair.
46
+ * Bundler3 legitimately receives tokens as an intermediary (user → bundler3 →
47
+ * vault), so gross inbound would fire false positives. We only flag when
48
+ * `|net| > DUST_THRESHOLD` — catching both **retention** (tokens stuck in
49
+ * bundler, positive net) and **drain** (bundler sending more than it received
50
+ * in this bundle, negative net — which implies a pre-existing or state-override
51
+ * balance is being drawn down, a red flag for pre-broadcast integrity).
52
+ */
53
+ export function assertNoBundlerRetention(params) {
54
+ const { chainId, transfers, logger } = params;
55
+ const bundlerAddresses = getBundlerAddresses(chainId, logger);
56
+ if (bundlerAddresses.size === 0)
57
+ return;
58
+ // Map keyed by (bundler, token) → structured entry. Avoids string parse-back.
59
+ const flow = new Map();
60
+ const recordFlow = (address, token, delta) => {
61
+ const key = `${address.toLowerCase()}:${token.toLowerCase()}`;
62
+ const existing = flow.get(key);
63
+ if (existing) {
64
+ existing.netChange += delta;
65
+ }
66
+ else {
67
+ flow.set(key, { address, token, netChange: delta });
68
+ }
69
+ };
70
+ for (const t of transfers) {
71
+ if (bundlerAddresses.has(t.to))
72
+ recordFlow(t.to, t.token, t.amount);
73
+ if (bundlerAddresses.has(t.from))
74
+ recordFlow(t.from, t.token, -t.amount);
75
+ }
76
+ const flagged = [...flow.values()].filter((e) => absBigInt(e.netChange) > DUST_THRESHOLD);
77
+ if (flagged.length > 0) {
78
+ throw new BlacklistViolationError("Simulation detected asset transfers retained or drained by restricted bundler contracts", flagged.map((e) => ({
79
+ address: e.address,
80
+ token: e.token,
81
+ netRetained: e.netChange.toString(),
82
+ })));
83
+ }
84
+ }
85
+ function absBigInt(x) {
86
+ return x < 0n ? -x : x;
87
+ }
@@ -0,0 +1,25 @@
1
+ import type { BlockTag } from "viem";
2
+ import type { RawSimulationResult, SimulationConfig, SimulationTransaction } from "../../types.js";
3
+ /**
4
+ * Stage 4 of the simulate() pipeline.
5
+ *
6
+ * Dispatches the transaction bundle to the available backend with a shared timeout
7
+ * budget:
8
+ * - If Tenderly is configured for the chain, it gets `TENDERLY_BUDGET_RATIO` (60%) of
9
+ * the timeout. On `ExternalServiceError`, falls back to `eth_simulateV1` with
10
+ * `max(remaining, FALLBACK_MIN_BUDGET_MS)` so the fallback still has a viable
11
+ * window when Tenderly eats its full slice. `SimulationRevertedError` (contract
12
+ * revert) propagates immediately without retry — a revert is a property of the
13
+ * bundle, not the backend.
14
+ * - If only `eth_simulateV1` is configured, it gets the full timeout.
15
+ *
16
+ * `shareable` is Tenderly-only: when true, the backend persists the simulation and
17
+ * returns a shareable URL. `eth_simulateV1` ignores it (no persistence concept).
18
+ */
19
+ export declare function executeSimulation(params: {
20
+ config: SimulationConfig;
21
+ chainId: number;
22
+ transactions: SimulationTransaction[];
23
+ blockNumber?: bigint | BlockTag;
24
+ shareable: boolean;
25
+ }): Promise<RawSimulationResult>;