@crewhaus/tool-chaincall 0.7.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.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Shaping an `eth_simulateV1` bundle, and reading the two different answers
3
+ * a node can give back.
4
+ *
5
+ * The honest-degradation rule lives here. `eth_simulateV1` runs an ordered
6
+ * list of calls against one block WITH the state each call leaves behind
7
+ * visible to the next — that chaining is the entire question a bundle asks.
8
+ * `eth_call` cannot do it. Every call an `eth_call` fallback makes starts
9
+ * from the same unmodified block, so "approve then swap" becomes "approve,
10
+ * and separately, a swap against a block where the approval never happened",
11
+ * which reverts or, worse, does not.
12
+ *
13
+ * So the fallback answers a DIFFERENT question and says so: `mode` names the
14
+ * mechanism, `chained` is false, and the effects a chained run would have
15
+ * produced — logs, balance deltas — are not present at all. Emitting the
16
+ * subset that happens to be computable is the failure mode worth designing
17
+ * against: a policy gate reading `logs: []` cannot tell "no events" from
18
+ * "events were not observable", and the first reading is a green light.
19
+ */
20
+ import { decodeRevertData } from "@crewhaus/tool-onchain";
21
+ import { data as asData, optionalQuantity, quantity } from "./hex";
22
+ import { ChainCallError, rpcError } from "./rpc";
23
+ /** Turn a decimal-string amount into the wire's hex quantity. */
24
+ function toWireQuantity(value, what) {
25
+ if (value === undefined)
26
+ return undefined;
27
+ const text = value.trim();
28
+ if (!/^\d+$/.test(text)) {
29
+ throw new ChainCallError(`${what} must be a decimal integer string, got ${JSON.stringify(value)}`);
30
+ }
31
+ return `0x${BigInt(text).toString(16)}`;
32
+ }
33
+ /** The wire object for one call, with absent fields omitted rather than nulled. */
34
+ export function toWireCall(call, index) {
35
+ const value = toWireQuantity(call.value, `calls[${index}].value`);
36
+ const gas = toWireQuantity(call.gas, `calls[${index}].gas`);
37
+ return {
38
+ to: call.to,
39
+ ...(call.from !== undefined ? { from: call.from } : {}),
40
+ ...(call.data !== undefined ? { input: asData(call.data, `calls[${index}].data`) } : {}),
41
+ ...(value !== undefined ? { value } : {}),
42
+ ...(gas !== undefined ? { gas } : {}),
43
+ };
44
+ }
45
+ function asRecord(value, what) {
46
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
47
+ throw new ChainCallError(`${what}: expected an object, got ${JSON.stringify(value)}`);
48
+ }
49
+ return value;
50
+ }
51
+ /**
52
+ * Read the one block `eth_simulateV1` returns for a one-block bundle.
53
+ *
54
+ * The call count is checked before anything is read out of it, for the same
55
+ * reason a Multicall3 blob's count is: results are positional, and a node
56
+ * that returned fewer than were sent would otherwise have every later result
57
+ * attributed to the wrong call.
58
+ */
59
+ export function parseSimulateV1(raw, expectedCalls) {
60
+ if (!Array.isArray(raw) || raw.length === 0) {
61
+ throw new ChainCallError(`eth_simulateV1 returned ${Array.isArray(raw) ? "no blocks" : "something that is not a list of blocks"}`);
62
+ }
63
+ if (raw.length > 1) {
64
+ throw new ChainCallError(`eth_simulateV1 returned ${raw.length} blocks for a one-block bundle — refusing to guess which one holds the answer`);
65
+ }
66
+ const block = asRecord(raw[0], "eth_simulateV1's block");
67
+ const calls = block["calls"];
68
+ if (!Array.isArray(calls)) {
69
+ throw new ChainCallError("eth_simulateV1's block carries no call results");
70
+ }
71
+ if (calls.length !== expectedCalls) {
72
+ throw new ChainCallError(`eth_simulateV1 returned ${calls.length} result(s) for ${expectedCalls} call(s) — refusing to pair them up, because results are matched by position and nothing in them names the call they answer`);
73
+ }
74
+ return {
75
+ blockNumber: quantity(block["number"], "the simulated block's number").toString(),
76
+ calls: calls.map((entry, index) => readCallResult(entry, index)),
77
+ };
78
+ }
79
+ function readCallResult(entry, index) {
80
+ const record = asRecord(entry, `eth_simulateV1's result for call ${index}`);
81
+ const returnData = asData(record["returnData"] ?? "0x", `eth_simulateV1's returnData for call ${index}`);
82
+ const status = optionalQuantity(record["status"], `the status of call ${index}`);
83
+ if (status === undefined && record["error"] === undefined) {
84
+ // An outcome nobody reported is not an outcome that held. Defaulting to
85
+ // "success" here is how a bundle whose result the node never stated walks
86
+ // through a gate that only reads `status`.
87
+ throw new ChainCallError(`eth_simulateV1 returned a result for call ${index} carrying neither a status nor an error, so this node did not say whether the call succeeded. Reading that as a success would turn an answer nobody gave into a green light, so the bundle is refused instead.`);
88
+ }
89
+ const failed = status === undefined ? true : status === 0n;
90
+ const gasUsed = optionalQuantity(record["gasUsed"], `gasUsed on call ${index}`);
91
+ const rawLogs = record["logs"];
92
+ // Absence, not emptiness — the same rule the eth_call fallback keeps. A node
93
+ // that returned no log list for this call did not say the call emitted
94
+ // nothing; `logs: []` is the one reading a policy gate acts on and is wrong
95
+ // about, so the key is left off entirely and the caller is told why.
96
+ const logs = Array.isArray(rawLogs) ? rawLogs.map((log, i) => readLog(log, index, i)) : undefined;
97
+ const nodeError = asRecord(record["error"] ?? {}, `the error on call ${index}`)["message"];
98
+ return {
99
+ index,
100
+ status: failed ? "reverted" : "success",
101
+ // A chain quantity the node did not report stays absent. "0" is a gas
102
+ // figure, and a call that looks free is a call somebody will believe is.
103
+ ...(gasUsed !== undefined ? { gasUsed: gasUsed.toString() } : {}),
104
+ returnData,
105
+ // A revert's bytes are the returnData: `Error(string)`, a `Panic`, or a
106
+ // custom error's selector. `decodeRevertData` keeps the hex when it
107
+ // cannot read them rather than inventing a message.
108
+ ...(failed ? { revert: decodeRevertData(returnData) } : {}),
109
+ ...(logs !== undefined ? { logs } : {}),
110
+ ...(typeof nodeError === "string" ? { error: nodeError } : {}),
111
+ };
112
+ }
113
+ function readLog(entry, callIndex, logIndex) {
114
+ const record = asRecord(entry, `log ${logIndex} of call ${callIndex}`);
115
+ const topics = record["topics"];
116
+ return {
117
+ address: String(record["address"] ?? ""),
118
+ topics: Array.isArray(topics) ? topics.map((t) => String(t)) : [],
119
+ data: asData(record["data"] ?? "0x", `log ${logIndex} of call ${callIndex}`),
120
+ };
121
+ }
122
+ /**
123
+ * Read an `eth_call` refusal as a revert when it carries revert bytes.
124
+ *
125
+ * Geth-family nodes answer a reverted `eth_call` with error code 3 and the
126
+ * revert data in `error.data`. Everything else — a bad parameter, a node
127
+ * that cannot serve the block — has no such bytes, and is not a revert.
128
+ */
129
+ export function revertFromCallError(err) {
130
+ const { data: payload } = rpcError(err);
131
+ if (typeof payload !== "string" || !/^0x([0-9a-fA-F]{2})*$/.test(payload))
132
+ return undefined;
133
+ return decodeRevertData(payload);
134
+ }
135
+ /**
136
+ * The chaining limitation, worded for what the fallback actually did.
137
+ *
138
+ * "Every call was evaluated against the same unmodified block" is only true
139
+ * when the calls carried a block NUMBER. Sending a tag once per call lets the
140
+ * head move between them, which is the same silent inconsistency `EvmMulticall`
141
+ * refuses to produce — so when the height could not be pinned the sentence
142
+ * says that instead of asserting a block they may not share.
143
+ */
144
+ export function unchainedLimitation(pinnedBlock) {
145
+ return pinnedBlock === null
146
+ ? "state is NOT chained, AND these calls do not even share a block: the block tag could not be resolved to a height, so each eth_call resolved it on its own and the head may have moved between them"
147
+ : `state is NOT chained: every call was evaluated against block ${pinnedBlock}, unmodified, so a call that depends on an earlier call's effect (an approval before a transfer, a deposit before a withdraw) did not see it`;
148
+ }
149
+ /** What a fallback result is missing, said in full rather than implied. */
150
+ export const FALLBACK_LIMITATIONS = Object.freeze([
151
+ "logs are omitted entirely rather than reported per call — an eth_call answers with return data only, and an empty log list would read as 'this emitted no events'",
152
+ "balance deltas are omitted entirely — without chaining there is no after-state to difference against",
153
+ "gas is per call against the unmodified block, so the figures do not add up to what the sequence would cost",
154
+ ]);
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@crewhaus/tool-chaincall",
3
+ "version": "0.7.0",
4
+ "type": "module",
5
+ "description": "Deterministic EVM contract reads: batched calls, contract introspection, gas market and simulation",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "test": "bun test src"
16
+ },
17
+ "dependencies": {
18
+ "@crewhaus/chain-adapter-base": "0.7.0",
19
+ "@crewhaus/errors": "0.7.0",
20
+ "@crewhaus/tool-builder": "0.7.0",
21
+ "@crewhaus/tool-catalog": "0.7.0",
22
+ "@crewhaus/tool-onchain": "0.7.0",
23
+ "zod": "^3.23.8"
24
+ },
25
+ "devDependencies": {
26
+ "@crewhaus/tool-executor": "workspace:*"
27
+ },
28
+ "license": "Apache-2.0",
29
+ "author": {
30
+ "name": "Max Meier",
31
+ "email": "max@crewhaus.ai",
32
+ "url": "https://crewhaus.ai"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/crewhaus/factory.git",
37
+ "directory": "packages/tool-chaincall"
38
+ },
39
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-chaincall#readme",
40
+ "bugs": {
41
+ "url": "https://github.com/crewhaus/factory/issues"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
47
+ }