@bananapus/nana-sdk-core 2.14.0 → 2.16.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.
- package/dist/cjs/review/contractWrite.d.ts +9 -1
- package/dist/cjs/review/contractWrite.d.ts.map +1 -1
- package/dist/cjs/review/contractWrite.js +20 -5
- package/dist/cjs/review/contractWrite.js.map +1 -1
- package/dist/cjs/review/decode.d.ts +138 -0
- package/dist/cjs/review/decode.d.ts.map +1 -0
- package/dist/cjs/review/decode.js +1118 -0
- package/dist/cjs/review/decode.js.map +1 -0
- package/dist/cjs/review/index.d.ts +2 -0
- package/dist/cjs/review/index.d.ts.map +1 -1
- package/dist/cjs/review/index.js +2 -0
- package/dist/cjs/review/index.js.map +1 -1
- package/dist/cjs/review/receipt.d.ts +32 -0
- package/dist/cjs/review/receipt.d.ts.map +1 -0
- package/dist/cjs/review/receipt.js +57 -0
- package/dist/cjs/review/receipt.js.map +1 -0
- package/dist/cjs/review/simulation.d.ts +47 -0
- package/dist/cjs/review/simulation.d.ts.map +1 -0
- package/dist/cjs/review/simulation.js +142 -0
- package/dist/cjs/review/simulation.js.map +1 -0
- package/dist/cjs/safeService.d.ts +15 -0
- package/dist/cjs/safeService.d.ts.map +1 -1
- package/dist/cjs/safeService.js +58 -25
- package/dist/cjs/safeService.js.map +1 -1
- package/dist/cjs/v6/feeBuyback.d.ts +27 -0
- package/dist/cjs/v6/feeBuyback.d.ts.map +1 -1
- package/dist/cjs/v6/feeBuyback.js +66 -0
- package/dist/cjs/v6/feeBuyback.js.map +1 -1
- package/dist/esm/review/contractWrite.d.ts +9 -1
- package/dist/esm/review/contractWrite.d.ts.map +1 -1
- package/dist/esm/review/contractWrite.js +19 -5
- package/dist/esm/review/contractWrite.js.map +1 -1
- package/dist/esm/review/decode.d.ts +138 -0
- package/dist/esm/review/decode.d.ts.map +1 -0
- package/dist/esm/review/decode.js +1102 -0
- package/dist/esm/review/decode.js.map +1 -0
- package/dist/esm/review/index.d.ts +2 -0
- package/dist/esm/review/index.d.ts.map +1 -1
- package/dist/esm/review/index.js +2 -0
- package/dist/esm/review/index.js.map +1 -1
- package/dist/esm/review/receipt.d.ts +32 -0
- package/dist/esm/review/receipt.d.ts.map +1 -0
- package/dist/esm/review/receipt.js +51 -0
- package/dist/esm/review/receipt.js.map +1 -0
- package/dist/esm/review/simulation.d.ts +47 -0
- package/dist/esm/review/simulation.d.ts.map +1 -0
- package/dist/esm/review/simulation.js +137 -0
- package/dist/esm/review/simulation.js.map +1 -0
- package/dist/esm/safeService.d.ts +15 -0
- package/dist/esm/safeService.d.ts.map +1 -1
- package/dist/esm/safeService.js +57 -25
- package/dist/esm/safeService.js.map +1 -1
- package/dist/esm/v6/feeBuyback.d.ts +27 -0
- package/dist/esm/v6/feeBuyback.d.ts.map +1 -1
- package/dist/esm/v6/feeBuyback.js +62 -0
- package/dist/esm/v6/feeBuyback.js.map +1 -1
- package/package.json +6 -1
|
@@ -0,0 +1,1102 @@
|
|
|
1
|
+
import { decodeAbiParameters, decodeFunctionData, encodeAbiParameters, encodeFunctionData, erc20Abi, formatEther, toFunctionSelector, zeroAddress, } from "viem";
|
|
2
|
+
import { SPLITS_TOTAL_PERCENT, USDC_ADDRESSES } from "../constants.js";
|
|
3
|
+
import { jbBuybackHookAbi, jbBuybackHookRegistryAbi, jbContractAddress, jbContractAddressHistory, jbControllerAbi, jbDirectoryAbi, jbMultiTerminalAbi, jbPermissionsAbi, jbProjectsAbi, jbRouterTerminalGatewayAbi, jbRouterTerminalRegistryAbi, jbSplitsAbi, jbTokensAbi, } from "../generated/juicebox.js";
|
|
4
|
+
import { permissionKeyV6 } from "../v6/permissions.js";
|
|
5
|
+
import { describeStickySplit, isStickySplit } from "../v6/sticky.js";
|
|
6
|
+
import { UNISWAP_PERMIT2_ADDRESS, UNISWAP_V4_UNIVERSAL_ROUTER_ADDRESSES, } from "../v6/uniswapV4Deployments.js";
|
|
7
|
+
/**
|
|
8
|
+
* Decode `data` as `params`, requiring their canonical encoding byte for
|
|
9
|
+
* byte. Dirty padding, trailing bytes or unusual offsets throw.
|
|
10
|
+
*/
|
|
11
|
+
function decodeExact(params, data) {
|
|
12
|
+
const decoded = decodeAbiParameters(params, data);
|
|
13
|
+
if (encodeAbiParameters(params, decoded).toLowerCase() !==
|
|
14
|
+
data.toLowerCase()) {
|
|
15
|
+
throw new Error("Noncanonical ABI encoding.");
|
|
16
|
+
}
|
|
17
|
+
return decoded;
|
|
18
|
+
}
|
|
19
|
+
let addressNames;
|
|
20
|
+
const addressKey = (chainId, address) => `${chainId}:${address.toLowerCase()}`;
|
|
21
|
+
/** Every named deployment by chain and address, built on first use. */
|
|
22
|
+
function namedAddresses() {
|
|
23
|
+
if (addressNames)
|
|
24
|
+
return addressNames;
|
|
25
|
+
const names = new Map();
|
|
26
|
+
// A later table takes precedence for an address two tables share.
|
|
27
|
+
const add = (table, name) => {
|
|
28
|
+
for (const [chainId, address] of Object.entries(table)) {
|
|
29
|
+
names.set(addressKey(chainId, address), name);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const history = jbContractAddressHistory["6"];
|
|
33
|
+
for (const [contract, table] of Object.entries(jbContractAddress["6"])) {
|
|
34
|
+
add(table, contract in history ? `${contract} (current)` : contract);
|
|
35
|
+
}
|
|
36
|
+
// A retired generation keeps its name, so a project that still selects it
|
|
37
|
+
// reads as "JBRouterTerminal (previous)" rather than as an unknown address.
|
|
38
|
+
for (const [contract, generations] of Object.entries(history)) {
|
|
39
|
+
for (const [generation, table] of Object.entries(generations)) {
|
|
40
|
+
add(table, `${contract} (${generation})`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
add(USDC_ADDRESSES, "USDC");
|
|
44
|
+
add(UNISWAP_V4_UNIVERSAL_ROUTER_ADDRESSES, "Uniswap Universal Router");
|
|
45
|
+
return (addressNames = names);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The name of a Juicebox V6 deployment, Permit2, the Uniswap Universal Router
|
|
49
|
+
* or USDC at `value` on `chainId`, or null. A contract with retired
|
|
50
|
+
* generations names the generation: "JBBuybackHook (current)",
|
|
51
|
+
* "JBBuybackHook (previous)".
|
|
52
|
+
*/
|
|
53
|
+
export function knownAddressName(chainId, value) {
|
|
54
|
+
if (typeof value !== "string" || !/^0x[0-9a-f]{40}$/iu.test(value)) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (value.toLowerCase() === UNISWAP_PERMIT2_ADDRESS.toLowerCase()) {
|
|
58
|
+
return "Permit2";
|
|
59
|
+
}
|
|
60
|
+
return namedAddresses().get(addressKey(chainId, value)) ?? null;
|
|
61
|
+
}
|
|
62
|
+
/** "Name | 0x…" for a known address, otherwise the address. */
|
|
63
|
+
function addressLabel(chainId, address) {
|
|
64
|
+
const name = knownAddressName(chainId, address);
|
|
65
|
+
return name ? `${name} | ${address}` : address;
|
|
66
|
+
}
|
|
67
|
+
/** Like {@link addressLabel}, for a Uniswap currency, where address zero is native ETH. */
|
|
68
|
+
function currencyLabel(chainId, currency) {
|
|
69
|
+
return currency.toLowerCase() === zeroAddress
|
|
70
|
+
? `native ETH | ${currency}`
|
|
71
|
+
: addressLabel(chainId, currency);
|
|
72
|
+
}
|
|
73
|
+
// ── Argument values ──────────────────────────────────────────────────────────
|
|
74
|
+
function stringify(value) {
|
|
75
|
+
try {
|
|
76
|
+
return (JSON.stringify(value, (_, item) => (typeof item === "bigint" ? item.toString() : item), 2) ?? String(value));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return String(value);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* One ABI value as review text. An address carries its known name
|
|
84
|
+
* ("JBController | 0x…"), and long bytes are shortened; the raw payload keeps
|
|
85
|
+
* the full value.
|
|
86
|
+
*/
|
|
87
|
+
export function readableValue(type, value, chainId) {
|
|
88
|
+
if (value === null || value === undefined)
|
|
89
|
+
return "—";
|
|
90
|
+
if (typeof value === "bigint")
|
|
91
|
+
return value.toString();
|
|
92
|
+
if (typeof value === "boolean" || typeof value === "number") {
|
|
93
|
+
return String(value);
|
|
94
|
+
}
|
|
95
|
+
if (typeof value === "string") {
|
|
96
|
+
if (type === "address")
|
|
97
|
+
return addressLabel(chainId, value);
|
|
98
|
+
if (type.startsWith("bytes") && value.length > 50) {
|
|
99
|
+
return `${value.slice(0, 22)}…${value.slice(-12)}`;
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
return stringify(value);
|
|
104
|
+
}
|
|
105
|
+
/** A tuple component's value, from a positional array or a named object. */
|
|
106
|
+
export function namedValue(value, name, index) {
|
|
107
|
+
if (Array.isArray(value))
|
|
108
|
+
return value[index];
|
|
109
|
+
if (value && typeof value === "object") {
|
|
110
|
+
const row = value;
|
|
111
|
+
return row[name] ?? row[index];
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
/** A native amount as "0.5 ETH | 500000000000000000 wei". */
|
|
116
|
+
export function nativeValue(value = 0n) {
|
|
117
|
+
return `${formatEther(value)} ETH | ${value.toString()} wei`;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The ABI function a reviewed call actually invokes, or null. It is the item
|
|
121
|
+
* named `functionName` whose selector is the calldata's first four bytes, and
|
|
122
|
+
* only when `args` encode to exactly that calldata. The dialog renders `args`,
|
|
123
|
+
* so a name alone, or arguments the bytes don't carry, never match.
|
|
124
|
+
*/
|
|
125
|
+
export function functionFromCall(call) {
|
|
126
|
+
if (!call.abi || !call.functionName)
|
|
127
|
+
return null;
|
|
128
|
+
const selector = call.data.slice(0, 10).toLowerCase();
|
|
129
|
+
const item = call.abi.find((entry) => entry.type === "function" &&
|
|
130
|
+
entry.name === call.functionName &&
|
|
131
|
+
toFunctionSelector(entry) === selector);
|
|
132
|
+
if (!item)
|
|
133
|
+
return null;
|
|
134
|
+
try {
|
|
135
|
+
const encoded = encodeFunctionData({
|
|
136
|
+
abi: [item],
|
|
137
|
+
functionName: item.name,
|
|
138
|
+
args: call.args ?? [],
|
|
139
|
+
});
|
|
140
|
+
return encoded.toLowerCase() === call.data.toLowerCase() ? item : null;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The review's lead paragraph. Callers assemble descriptions from optional
|
|
148
|
+
* parts, so a blank one falls back to the standing guidance instead of an
|
|
149
|
+
* empty banner.
|
|
150
|
+
*/
|
|
151
|
+
export function reviewDescription(request) {
|
|
152
|
+
const description = request.description?.trim();
|
|
153
|
+
if (description)
|
|
154
|
+
return description;
|
|
155
|
+
if (request.kind === "authorization") {
|
|
156
|
+
return "This authorization commits to the exact destination, native value, and calldata below. A Safe or relayer can submit that call onchain after you continue.";
|
|
157
|
+
}
|
|
158
|
+
return `This is the exact destination, native value, and calldata the app will ask your wallet to send. ${request.calls.every((call) => call.gas !== undefined || call.safeTxGas !== undefined)
|
|
159
|
+
? "Your wallet adds the nonce and network fees."
|
|
160
|
+
: "Your wallet shows the gas limit and network fees before you send."}`;
|
|
161
|
+
}
|
|
162
|
+
const POOL_KEY = {
|
|
163
|
+
type: "tuple",
|
|
164
|
+
components: [
|
|
165
|
+
{ type: "address" },
|
|
166
|
+
{ type: "address" },
|
|
167
|
+
{ type: "uint24" },
|
|
168
|
+
{ type: "int24" },
|
|
169
|
+
{ type: "address" },
|
|
170
|
+
],
|
|
171
|
+
};
|
|
172
|
+
/** A position change: `(tokenId, liquidity, amount0, amount1, hookData)`. */
|
|
173
|
+
const MODIFY_LIQUIDITY = [
|
|
174
|
+
{ type: "uint256" },
|
|
175
|
+
{ type: "uint256" },
|
|
176
|
+
{ type: "uint128" },
|
|
177
|
+
{ type: "uint128" },
|
|
178
|
+
{ type: "bytes" },
|
|
179
|
+
];
|
|
180
|
+
/** An action list: `(bytes actions, bytes[] params)`, one param per action byte. */
|
|
181
|
+
const ACTION_PLAN = [{ type: "bytes" }, { type: "bytes[]" }];
|
|
182
|
+
/**
|
|
183
|
+
* Decode a Uniswap V4 PositionManager `unlockData` plan into typed steps.
|
|
184
|
+
* It covers the actions the apps build (increase, decrease, mint, burn, take
|
|
185
|
+
* pair, close, sweep) with empty hook data and canonical encodings; anything
|
|
186
|
+
* else is null. Amounts stay in raw token units and addresses stay raw, since
|
|
187
|
+
* the dialog shows the exact payload.
|
|
188
|
+
*/
|
|
189
|
+
export function describeV4UnlockData(value) {
|
|
190
|
+
if (typeof value !== "string" || !value.startsWith("0x"))
|
|
191
|
+
return null;
|
|
192
|
+
try {
|
|
193
|
+
const [actions, params] = decodeExact(ACTION_PLAN, value);
|
|
194
|
+
const codes = actions.slice(2).match(/.{2}/g) ?? [];
|
|
195
|
+
if (!codes.length || codes.length !== params.length)
|
|
196
|
+
return null;
|
|
197
|
+
const steps = [];
|
|
198
|
+
for (const [index, byte] of codes.entries()) {
|
|
199
|
+
const data = params[index];
|
|
200
|
+
switch (parseInt(byte, 16)) {
|
|
201
|
+
case 0x00: {
|
|
202
|
+
const [tokenId, liquidity, amount0Max, amount1Max, hookData] = decodeExact(MODIFY_LIQUIDITY, data);
|
|
203
|
+
if (hookData !== "0x")
|
|
204
|
+
return null;
|
|
205
|
+
steps.push({
|
|
206
|
+
action: "INCREASE_LIQUIDITY",
|
|
207
|
+
position: `#${tokenId}`,
|
|
208
|
+
liquidity,
|
|
209
|
+
maximumIn: { currency0: amount0Max, currency1: amount1Max },
|
|
210
|
+
});
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
case 0x01: {
|
|
214
|
+
// The liquidity word is a uint256 in the PositionManager's decoder.
|
|
215
|
+
const [tokenId, liquidity, amount0Min, amount1Min, hookData] = decodeExact(MODIFY_LIQUIDITY, data);
|
|
216
|
+
if (hookData !== "0x")
|
|
217
|
+
return null;
|
|
218
|
+
steps.push({
|
|
219
|
+
action: "DECREASE_LIQUIDITY",
|
|
220
|
+
position: `#${tokenId}`,
|
|
221
|
+
liquidity,
|
|
222
|
+
minimumOut: { currency0: amount0Min, currency1: amount1Min },
|
|
223
|
+
});
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
case 0x02: {
|
|
227
|
+
const [key, tickLower, tickUpper, liquidity, amount0Max, amount1Max, owner, hookData,] = decodeExact([
|
|
228
|
+
POOL_KEY,
|
|
229
|
+
{ type: "int24" },
|
|
230
|
+
{ type: "int24" },
|
|
231
|
+
{ type: "uint256" },
|
|
232
|
+
{ type: "uint128" },
|
|
233
|
+
{ type: "uint128" },
|
|
234
|
+
{ type: "address" },
|
|
235
|
+
{ type: "bytes" },
|
|
236
|
+
], data);
|
|
237
|
+
if (hookData !== "0x")
|
|
238
|
+
return null;
|
|
239
|
+
steps.push({
|
|
240
|
+
action: "MINT_POSITION",
|
|
241
|
+
owner,
|
|
242
|
+
pool: {
|
|
243
|
+
currency0: key[0],
|
|
244
|
+
currency1: key[1],
|
|
245
|
+
fee: key[2],
|
|
246
|
+
tickSpacing: key[3],
|
|
247
|
+
hook: key[4],
|
|
248
|
+
},
|
|
249
|
+
ticks: { lower: tickLower, upper: tickUpper },
|
|
250
|
+
liquidity,
|
|
251
|
+
maximumIn: { currency0: amount0Max, currency1: amount1Max },
|
|
252
|
+
});
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
case 0x03: {
|
|
256
|
+
const [tokenId, amount0Min, amount1Min, hookData] = decodeExact([
|
|
257
|
+
{ type: "uint256" },
|
|
258
|
+
{ type: "uint128" },
|
|
259
|
+
{ type: "uint128" },
|
|
260
|
+
{ type: "bytes" },
|
|
261
|
+
], data);
|
|
262
|
+
if (hookData !== "0x")
|
|
263
|
+
return null;
|
|
264
|
+
steps.push({
|
|
265
|
+
action: "BURN_POSITION",
|
|
266
|
+
position: `#${tokenId}`,
|
|
267
|
+
minimumOut: { currency0: amount0Min, currency1: amount1Min },
|
|
268
|
+
});
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
case 0x11: {
|
|
272
|
+
const [currency0, currency1, recipient] = decodeExact([{ type: "address" }, { type: "address" }, { type: "address" }], data);
|
|
273
|
+
steps.push({ action: "TAKE_PAIR", currency0, currency1, recipient });
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
case 0x12: {
|
|
277
|
+
const [currency] = decodeExact([{ type: "address" }], data);
|
|
278
|
+
steps.push({ action: "CLOSE_CURRENCY", currency });
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
case 0x14: {
|
|
282
|
+
const [currency, recipient] = decodeExact([{ type: "address" }, { type: "address" }], data);
|
|
283
|
+
steps.push({ action: "SWEEP", currency, recipient });
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
default:
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return steps;
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// ── Uniswap Universal Router ─────────────────────────────────────────────────
|
|
297
|
+
/** Universal Router sentinels the direct-pay swap builders use. */
|
|
298
|
+
const UR_MSG_SENDER = "0x0000000000000000000000000000000000000001";
|
|
299
|
+
const UR_ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
|
|
300
|
+
const UR_CONTRACT_BALANCE = 0x8000000000000000000000000000000000000000000000000000000000000000n;
|
|
301
|
+
const CONTRACT_BALANCE_TEXT = "the router's entire balance from the previous step";
|
|
302
|
+
function urRecipient(chainId, address) {
|
|
303
|
+
if (address.toLowerCase() === UR_MSG_SENDER)
|
|
304
|
+
return "you (msg.sender)";
|
|
305
|
+
if (address.toLowerCase() === UR_ADDRESS_THIS) {
|
|
306
|
+
return "the router (kept for the next step)";
|
|
307
|
+
}
|
|
308
|
+
return addressLabel(chainId, address);
|
|
309
|
+
}
|
|
310
|
+
/** A V3 swap or wrap amount: the contract-balance sentinel, else the number. */
|
|
311
|
+
function routerAmount(value) {
|
|
312
|
+
return value === UR_CONTRACT_BALANCE
|
|
313
|
+
? CONTRACT_BALANCE_TEXT
|
|
314
|
+
: value.toString();
|
|
315
|
+
}
|
|
316
|
+
/** A V4 swap or take amount, where 0 takes the open delta the previous step left. */
|
|
317
|
+
function openDeltaAmount(value) {
|
|
318
|
+
return value === 0n
|
|
319
|
+
? "0 (the open amount from the previous step)"
|
|
320
|
+
: value.toString();
|
|
321
|
+
}
|
|
322
|
+
/** A V4 settle amount, which reads both the contract-balance sentinel and 0. */
|
|
323
|
+
function settleAmount(value) {
|
|
324
|
+
return value === UR_CONTRACT_BALANCE
|
|
325
|
+
? CONTRACT_BALANCE_TEXT
|
|
326
|
+
: openDeltaAmount(value);
|
|
327
|
+
}
|
|
328
|
+
/** Who pays a router or V4 step. */
|
|
329
|
+
function payer(payerIsUser) {
|
|
330
|
+
return payerIsUser ? "you (via Permit2)" : "the router's balance";
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Unix seconds as a UTC time and the raw value, the same in every locale. A
|
|
334
|
+
* value past the last date JavaScript can show (a max uint48, say) keeps its
|
|
335
|
+
* raw value and says so.
|
|
336
|
+
*/
|
|
337
|
+
function unixTime(seconds) {
|
|
338
|
+
const value = BigInt(seconds);
|
|
339
|
+
if (value > 8640000000000n)
|
|
340
|
+
return `${value} (after year 275760)`;
|
|
341
|
+
const time = new Date(Number(value) * 1000).toISOString();
|
|
342
|
+
return `${time.replace(".000Z", " UTC").replace("T", " ")} (${value})`;
|
|
343
|
+
}
|
|
344
|
+
/** A packed V3 path: 20-byte token, 3-byte fee, 20-byte token, … */
|
|
345
|
+
function urV3Path(chainId, path) {
|
|
346
|
+
const raw = path.slice(2);
|
|
347
|
+
if (raw.length < 86 || (raw.length - 40) % 46 !== 0)
|
|
348
|
+
return null;
|
|
349
|
+
const parts = [addressLabel(chainId, `0x${raw.slice(0, 40)}`)];
|
|
350
|
+
for (let offset = 40; offset < raw.length; offset += 46) {
|
|
351
|
+
const fee = parseInt(raw.slice(offset, offset + 6), 16);
|
|
352
|
+
parts.push(`-${fee / 10_000}%→`, addressLabel(chainId, `0x${raw.slice(offset + 6, offset + 46)}`));
|
|
353
|
+
}
|
|
354
|
+
return parts.join(" ");
|
|
355
|
+
}
|
|
356
|
+
/** `SWAP_EXACT_IN_SINGLE`: `(poolKey, zeroForOne, amountIn, amountOutMinimum, hookData)`. */
|
|
357
|
+
const V4_EXACT_IN_SINGLE = [
|
|
358
|
+
{
|
|
359
|
+
type: "tuple",
|
|
360
|
+
components: [
|
|
361
|
+
POOL_KEY,
|
|
362
|
+
{ type: "bool" },
|
|
363
|
+
{ type: "uint128" },
|
|
364
|
+
{ type: "uint128" },
|
|
365
|
+
{ type: "bytes" },
|
|
366
|
+
],
|
|
367
|
+
},
|
|
368
|
+
];
|
|
369
|
+
/** The V4_SWAP command's inner action plan, in the shapes the pay builders emit. */
|
|
370
|
+
function urV4SwapSteps(chainId, input) {
|
|
371
|
+
const [actions, params] = decodeExact(ACTION_PLAN, input);
|
|
372
|
+
const codes = actions.slice(2).match(/.{2}/g) ?? [];
|
|
373
|
+
if (!codes.length || codes.length !== params.length)
|
|
374
|
+
return null;
|
|
375
|
+
const steps = [];
|
|
376
|
+
for (const [index, byte] of codes.entries()) {
|
|
377
|
+
const data = params[index];
|
|
378
|
+
switch (parseInt(byte, 16)) {
|
|
379
|
+
case 0x06: {
|
|
380
|
+
const [[key, zeroForOne, amountIn, minimumOut, hookData]] = decodeExact(V4_EXACT_IN_SINGLE, data);
|
|
381
|
+
if (hookData !== "0x")
|
|
382
|
+
return null;
|
|
383
|
+
steps.push({
|
|
384
|
+
title: "Swap in the project's V4 pool (exact input)",
|
|
385
|
+
rows: [
|
|
386
|
+
["Sell", currencyLabel(chainId, zeroForOne ? key[0] : key[1])],
|
|
387
|
+
["Buy", currencyLabel(chainId, zeroForOne ? key[1] : key[0])],
|
|
388
|
+
["Amount in", openDeltaAmount(amountIn)],
|
|
389
|
+
["Minimum out", `${minimumOut} — reverts below this`],
|
|
390
|
+
["Fee", `${key[2]} (${key[2] / 10_000}%) | tick spacing ${key[3]}`],
|
|
391
|
+
["Hook", addressLabel(chainId, key[4])],
|
|
392
|
+
],
|
|
393
|
+
});
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
case 0x0b: {
|
|
397
|
+
const [currency, amount, payerIsUser] = decodeExact([{ type: "address" }, { type: "uint256" }, { type: "bool" }], data);
|
|
398
|
+
steps.push({
|
|
399
|
+
title: "Pay the pool",
|
|
400
|
+
rows: [
|
|
401
|
+
["Currency", currencyLabel(chainId, currency)],
|
|
402
|
+
["Amount", settleAmount(amount)],
|
|
403
|
+
["Paid by", payer(payerIsUser)],
|
|
404
|
+
],
|
|
405
|
+
});
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
case 0x0c: {
|
|
409
|
+
const [currency, maximum] = decodeExact([{ type: "address" }, { type: "uint256" }], data);
|
|
410
|
+
steps.push({
|
|
411
|
+
title: "Pay the pool everything owed",
|
|
412
|
+
rows: [
|
|
413
|
+
["Currency", currencyLabel(chainId, currency)],
|
|
414
|
+
["At most", maximum.toString()],
|
|
415
|
+
],
|
|
416
|
+
});
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
case 0x0e: {
|
|
420
|
+
const [currency, recipient, amount] = decodeExact([{ type: "address" }, { type: "address" }, { type: "uint256" }], data);
|
|
421
|
+
steps.push({
|
|
422
|
+
title: "Take the swap output",
|
|
423
|
+
rows: [
|
|
424
|
+
["Currency", currencyLabel(chainId, currency)],
|
|
425
|
+
["Recipient", urRecipient(chainId, recipient)],
|
|
426
|
+
["Amount", openDeltaAmount(amount)],
|
|
427
|
+
],
|
|
428
|
+
});
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
default:
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return steps;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Decode a Uniswap Universal Router `execute(commands, inputs, deadline)` into
|
|
439
|
+
* readable steps. It covers the command shapes the pay flow builds (Permit2
|
|
440
|
+
* permit, wrap, V3 hop, unwrap, V4 swap) in canonical encodings; anything
|
|
441
|
+
* else is null.
|
|
442
|
+
*/
|
|
443
|
+
export function describeUniversalRouterExecute(chainId, args) {
|
|
444
|
+
if (!args || args.length < 2)
|
|
445
|
+
return null;
|
|
446
|
+
const [commands, inputs] = args;
|
|
447
|
+
if (typeof commands !== "string" ||
|
|
448
|
+
!commands.startsWith("0x") ||
|
|
449
|
+
!Array.isArray(inputs)) {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
const codes = commands.slice(2).match(/.{2}/g) ?? [];
|
|
454
|
+
if (!codes.length || codes.length !== inputs.length)
|
|
455
|
+
return null;
|
|
456
|
+
const steps = [];
|
|
457
|
+
for (const [index, byte] of codes.entries()) {
|
|
458
|
+
const data = inputs[index];
|
|
459
|
+
switch (parseInt(byte, 16)) {
|
|
460
|
+
case 0x00: {
|
|
461
|
+
const [recipient, amountIn, minimumOut, path, payerIsUser] = decodeExact([
|
|
462
|
+
{ type: "address" },
|
|
463
|
+
{ type: "uint256" },
|
|
464
|
+
{ type: "uint256" },
|
|
465
|
+
{ type: "bytes" },
|
|
466
|
+
{ type: "bool" },
|
|
467
|
+
], data);
|
|
468
|
+
const route = urV3Path(chainId, path);
|
|
469
|
+
if (!route)
|
|
470
|
+
return null;
|
|
471
|
+
steps.push({
|
|
472
|
+
title: "Swap through a V3 pool (exact input)",
|
|
473
|
+
rows: [
|
|
474
|
+
["Route", route],
|
|
475
|
+
["Amount in", routerAmount(amountIn)],
|
|
476
|
+
[
|
|
477
|
+
"Minimum out",
|
|
478
|
+
minimumOut === 0n
|
|
479
|
+
? "0 — the final V4 minimum below is the real floor"
|
|
480
|
+
: minimumOut.toString(),
|
|
481
|
+
],
|
|
482
|
+
["Paid by", payer(payerIsUser)],
|
|
483
|
+
["Recipient", urRecipient(chainId, recipient)],
|
|
484
|
+
],
|
|
485
|
+
});
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
case 0x0a: {
|
|
489
|
+
const [[details, spender, sigDeadline]] = decodeExact([
|
|
490
|
+
{
|
|
491
|
+
type: "tuple",
|
|
492
|
+
components: [
|
|
493
|
+
{
|
|
494
|
+
type: "tuple",
|
|
495
|
+
components: [
|
|
496
|
+
{ type: "address" },
|
|
497
|
+
{ type: "uint160" },
|
|
498
|
+
{ type: "uint48" },
|
|
499
|
+
{ type: "uint48" },
|
|
500
|
+
],
|
|
501
|
+
},
|
|
502
|
+
{ type: "address" },
|
|
503
|
+
{ type: "uint256" },
|
|
504
|
+
],
|
|
505
|
+
},
|
|
506
|
+
{ type: "bytes" },
|
|
507
|
+
], data);
|
|
508
|
+
steps.push({
|
|
509
|
+
title: "Apply your signed Permit2 authorization",
|
|
510
|
+
rows: [
|
|
511
|
+
["Token", addressLabel(chainId, details[0])],
|
|
512
|
+
["Amount", details[1].toString()],
|
|
513
|
+
["Spender", addressLabel(chainId, spender)],
|
|
514
|
+
[
|
|
515
|
+
"Expires",
|
|
516
|
+
// Permit2 stores an expiration of 0 as the current block's timestamp.
|
|
517
|
+
details[2] === 0 ? "0 (this block only)" : unixTime(details[2]),
|
|
518
|
+
],
|
|
519
|
+
["Signature deadline", unixTime(sigDeadline)],
|
|
520
|
+
],
|
|
521
|
+
});
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
case 0x0b: {
|
|
525
|
+
const [recipient, amount] = decodeExact([{ type: "address" }, { type: "uint256" }], data);
|
|
526
|
+
steps.push({
|
|
527
|
+
title: "Wrap ETH into WETH",
|
|
528
|
+
rows: [
|
|
529
|
+
["Amount", routerAmount(amount)],
|
|
530
|
+
["Recipient", urRecipient(chainId, recipient)],
|
|
531
|
+
],
|
|
532
|
+
});
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
case 0x0c: {
|
|
536
|
+
const [recipient, minimum] = decodeExact([{ type: "address" }, { type: "uint256" }], data);
|
|
537
|
+
steps.push({
|
|
538
|
+
title: "Unwrap WETH back to ETH",
|
|
539
|
+
rows: [
|
|
540
|
+
[
|
|
541
|
+
"Minimum",
|
|
542
|
+
minimum === 0n ? "0 (no minimum)" : minimum.toString(),
|
|
543
|
+
],
|
|
544
|
+
["Recipient", urRecipient(chainId, recipient)],
|
|
545
|
+
],
|
|
546
|
+
});
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
case 0x10: {
|
|
550
|
+
const inner = urV4SwapSteps(chainId, data);
|
|
551
|
+
if (!inner)
|
|
552
|
+
return null;
|
|
553
|
+
steps.push(...inner);
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
556
|
+
default:
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return steps;
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
// ── JB hook metadata (JBMetadataResolver envelope) ───────────────────────────
|
|
567
|
+
const bigintJson = (value) => JSON.stringify(value, (_, item) => typeof item === "bigint" ? item.toString() : item);
|
|
568
|
+
/** Strict decode and byte-exact re-encode, else null. */
|
|
569
|
+
function roundTripDecode(types, payload) {
|
|
570
|
+
try {
|
|
571
|
+
return decodeExact(types, payload);
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Parse the JBMetadataResolver layout exactly as `getDataFor` reads it: a
|
|
579
|
+
* 32-byte reserved word, a word-padded table of `(bytes4 id, uint8 wordOffset)`
|
|
580
|
+
* entries, then word-aligned payload segments. Offsets must strictly increase
|
|
581
|
+
* and the segments must tile the rest of the bytes.
|
|
582
|
+
*/
|
|
583
|
+
function parseHookMetadataEnvelope(value) {
|
|
584
|
+
if (typeof value !== "string" || !/^0x([0-9a-fA-F]{2})+$/.test(value)) {
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
const body = value.slice(2).toLowerCase();
|
|
588
|
+
if (body.length % 64 !== 0)
|
|
589
|
+
return null;
|
|
590
|
+
const totalWords = body.length / 64;
|
|
591
|
+
// A reserved word, a table word, and at least one payload word.
|
|
592
|
+
if (totalWords < 3)
|
|
593
|
+
return null;
|
|
594
|
+
const firstOffset = parseInt(body.slice(64 + 8, 64 + 10), 16);
|
|
595
|
+
const tableWords = firstOffset - 1;
|
|
596
|
+
if (tableWords < 1 || firstOffset >= totalWords)
|
|
597
|
+
return null;
|
|
598
|
+
const tableArea = body.slice(64, 64 + tableWords * 64);
|
|
599
|
+
const entries = [];
|
|
600
|
+
let cursor = 0;
|
|
601
|
+
while (cursor + 10 <= tableArea.length) {
|
|
602
|
+
const chunk = tableArea.slice(cursor, cursor + 10);
|
|
603
|
+
if (/^0+$/.test(chunk))
|
|
604
|
+
break;
|
|
605
|
+
const id = chunk.slice(0, 8);
|
|
606
|
+
const offset = parseInt(chunk.slice(8, 10), 16);
|
|
607
|
+
// A zero id with a nonzero offset is malformed. `getDataFor` returns the
|
|
608
|
+
// first entry for an id, so a repeated id would show dead data as live.
|
|
609
|
+
if (/^0+$/.test(id) || entries.some((entry) => entry.id === `0x${id}`)) {
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
entries.push({ id: `0x${id}`, offset });
|
|
613
|
+
cursor += 10;
|
|
614
|
+
}
|
|
615
|
+
// The rest of the table must be zero padding.
|
|
616
|
+
if (!/^0*$/.test(tableArea.slice(cursor)))
|
|
617
|
+
return null;
|
|
618
|
+
// The entry count must be what sized the table.
|
|
619
|
+
if (Math.ceil((entries.length * 5) / 32) !== tableWords)
|
|
620
|
+
return null;
|
|
621
|
+
// Offsets must ascend and the payloads must tile the remaining bytes exactly.
|
|
622
|
+
for (let i = 0; i < entries.length; i++) {
|
|
623
|
+
if (entries[i].offset >= totalWords)
|
|
624
|
+
return null;
|
|
625
|
+
if (i > 0 && entries[i].offset <= entries[i - 1].offset)
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
const segments = entries.map((entry, i) => {
|
|
629
|
+
const start = entry.offset * 64;
|
|
630
|
+
const end = i + 1 < entries.length ? entries[i + 1].offset * 64 : body.length;
|
|
631
|
+
return { id: entry.id, payload: `0x${body.slice(start, end)}` };
|
|
632
|
+
});
|
|
633
|
+
return { reserved: `0x${body.slice(0, 64)}`, entries: segments };
|
|
634
|
+
}
|
|
635
|
+
/** Repeated tier ids as "2× #4", in first-seen order. */
|
|
636
|
+
function tierIdCounts(tierIds) {
|
|
637
|
+
const counts = new Map();
|
|
638
|
+
for (const id of tierIds)
|
|
639
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
640
|
+
return [...counts.entries()]
|
|
641
|
+
.map(([id, count]) => (count > 1 ? `${count}× #${id}` : `#${id}`))
|
|
642
|
+
.join(", ");
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Decode a `pay`/`addToBalanceOf`/`cashOutTokensOf` `metadata` argument into
|
|
646
|
+
* its hook entries, reading the payload shapes the ecosystem's builders
|
|
647
|
+
* produce (721 mints and redeems, buyback routing). A payload that matches
|
|
648
|
+
* several shapes is reported as ambiguous instead of picking one.
|
|
649
|
+
*/
|
|
650
|
+
export function describeJBHookMetadata(context, value) {
|
|
651
|
+
const envelope = parseHookMetadataEnvelope(value);
|
|
652
|
+
if (!envelope)
|
|
653
|
+
return null;
|
|
654
|
+
const steps = [];
|
|
655
|
+
if (!/^0x0+$/.test(envelope.reserved)) {
|
|
656
|
+
steps.push({
|
|
657
|
+
title: "Protocol-reserved word (nonzero)",
|
|
658
|
+
rows: [["Value", envelope.reserved]],
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
for (const entry of envelope.entries) {
|
|
662
|
+
const payloadWords = (entry.payload.length - 2) / 64;
|
|
663
|
+
const base = [["Hook lookup id", entry.id]];
|
|
664
|
+
// Every known shape that round-trips byte for byte. Exactly one is an
|
|
665
|
+
// interpretation; several (degenerate payloads such as empty arrays) are
|
|
666
|
+
// ambiguous.
|
|
667
|
+
const readings = [];
|
|
668
|
+
if (context === "pay") {
|
|
669
|
+
const mint = roundTripDecode([{ type: "bool" }, { type: "uint16[]" }], entry.payload);
|
|
670
|
+
if (mint) {
|
|
671
|
+
readings.push({
|
|
672
|
+
title: "721 shop mint instructions",
|
|
673
|
+
rows: [
|
|
674
|
+
...base,
|
|
675
|
+
[
|
|
676
|
+
"Tier IDs to mint",
|
|
677
|
+
mint[1].length ? tierIdCounts(mint[1]) : "none (credits only)",
|
|
678
|
+
],
|
|
679
|
+
[
|
|
680
|
+
"Allow overspending",
|
|
681
|
+
mint[0]
|
|
682
|
+
? "yes — excess becomes pay credits"
|
|
683
|
+
: "no — any excess reverts",
|
|
684
|
+
],
|
|
685
|
+
],
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
if (payloadWords === 3) {
|
|
689
|
+
const buyback = roundTripDecode([{ type: "uint256" }, { type: "uint256" }, { type: "bool" }], entry.payload);
|
|
690
|
+
if (buyback) {
|
|
691
|
+
readings.push({
|
|
692
|
+
title: "Buyback hook swap instructions",
|
|
693
|
+
rows: [
|
|
694
|
+
...base,
|
|
695
|
+
["Amount to swap", buyback[0].toString()],
|
|
696
|
+
["Minimum swap output", `${buyback[1]} — reverts below this`],
|
|
697
|
+
["Skip splits on swapped tokens", buyback[2] ? "yes" : "no"],
|
|
698
|
+
],
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
else {
|
|
704
|
+
if (payloadWords === 2) {
|
|
705
|
+
const buyback = roundTripDecode([{ type: "uint256" }, { type: "bool" }], entry.payload);
|
|
706
|
+
if (buyback) {
|
|
707
|
+
readings.push({
|
|
708
|
+
title: "Buyback hook cash-out routing",
|
|
709
|
+
rows: [
|
|
710
|
+
...base,
|
|
711
|
+
["Minimum swap output", buyback[0].toString()],
|
|
712
|
+
[
|
|
713
|
+
"Force the direct terminal path",
|
|
714
|
+
buyback[1] ? "yes — never route through the pool" : "no",
|
|
715
|
+
],
|
|
716
|
+
],
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const redeem = roundTripDecode([{ type: "uint256[]" }], entry.payload);
|
|
721
|
+
if (redeem) {
|
|
722
|
+
readings.push({
|
|
723
|
+
title: "721 shop items to redeem",
|
|
724
|
+
rows: [
|
|
725
|
+
...base,
|
|
726
|
+
[
|
|
727
|
+
"Token IDs",
|
|
728
|
+
redeem[0].length
|
|
729
|
+
? redeem[0].map((id) => `#${id}`).join(", ")
|
|
730
|
+
: "none",
|
|
731
|
+
],
|
|
732
|
+
],
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
if (readings.length === 1) {
|
|
737
|
+
steps.push(readings[0]);
|
|
738
|
+
}
|
|
739
|
+
else if (readings.length > 1) {
|
|
740
|
+
steps.push({
|
|
741
|
+
title: "Payload matches multiple known shapes — verify against the raw bytes",
|
|
742
|
+
rows: [
|
|
743
|
+
...base,
|
|
744
|
+
...readings.map((reading, i) => [
|
|
745
|
+
`Reading ${i + 1}`,
|
|
746
|
+
`${reading.title}: ${reading.rows
|
|
747
|
+
.slice(base.length)
|
|
748
|
+
.map(([label, val]) => `${label.toLowerCase()}: ${val}`)
|
|
749
|
+
.join("; ")}`,
|
|
750
|
+
]),
|
|
751
|
+
],
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
else {
|
|
755
|
+
steps.push({
|
|
756
|
+
title: `Unrecognized hook payload (${payloadWords} word${payloadWords === 1 ? "" : "s"})`,
|
|
757
|
+
rows: [...base, ["Payload", entry.payload]],
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return steps;
|
|
762
|
+
}
|
|
763
|
+
// ── Sucker bridge claim ──────────────────────────────────────────────────────
|
|
764
|
+
/** A bytes32 that is a left-padded address reads as the address. */
|
|
765
|
+
function paddedAddress(value) {
|
|
766
|
+
if (/^0x000000000000000000000000[0-9a-fA-F]{40}$/.test(value)) {
|
|
767
|
+
return `0x${value.slice(26)}`;
|
|
768
|
+
}
|
|
769
|
+
return value;
|
|
770
|
+
}
|
|
771
|
+
/** A sucker `claim` argument: its leaf, with the 32-hash proof summarized. */
|
|
772
|
+
export function describeSuckerClaim(chainId, value) {
|
|
773
|
+
const claim = value;
|
|
774
|
+
if (!claim ||
|
|
775
|
+
typeof claim.token !== "string" ||
|
|
776
|
+
!claim.leaf ||
|
|
777
|
+
typeof claim.leaf.beneficiary !== "string" ||
|
|
778
|
+
typeof claim.leaf.index !== "bigint" ||
|
|
779
|
+
typeof claim.leaf.projectTokenCount !== "bigint" ||
|
|
780
|
+
typeof claim.leaf.terminalTokenAmount !== "bigint" ||
|
|
781
|
+
!Array.isArray(claim.proof) ||
|
|
782
|
+
claim.proof.length !== 32 ||
|
|
783
|
+
!claim.proof.every((hash) => typeof hash === "string" && /^0x[0-9a-fA-F]{64}$/.test(hash))) {
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
const rows = [
|
|
787
|
+
["Terminal token", addressLabel(chainId, claim.token)],
|
|
788
|
+
["Leaf index", claim.leaf.index.toString()],
|
|
789
|
+
["Beneficiary", paddedAddress(claim.leaf.beneficiary)],
|
|
790
|
+
["Project tokens", claim.leaf.projectTokenCount.toString()],
|
|
791
|
+
["Terminal token amount", claim.leaf.terminalTokenAmount.toString()],
|
|
792
|
+
];
|
|
793
|
+
if (typeof claim.leaf.metadata === "string" &&
|
|
794
|
+
!/^0x0+$/.test(claim.leaf.metadata)) {
|
|
795
|
+
rows.push(["Leaf metadata", claim.leaf.metadata]);
|
|
796
|
+
}
|
|
797
|
+
rows.push([
|
|
798
|
+
"Merkle proof",
|
|
799
|
+
"32 hashes — exact bytes in the raw payload below",
|
|
800
|
+
]);
|
|
801
|
+
return [
|
|
802
|
+
{ title: "Claim a bridged balance from the sucker's inbox tree", rows },
|
|
803
|
+
];
|
|
804
|
+
}
|
|
805
|
+
// ── Safe execTransaction inner call ──────────────────────────────────────────
|
|
806
|
+
let safeInnerAbis;
|
|
807
|
+
/** The ABI a queued Safe call to a Juicebox deployment is read with, by deployment name. */
|
|
808
|
+
function safeInnerAbiOf(contract) {
|
|
809
|
+
safeInnerAbis ??= new Map([
|
|
810
|
+
["JBController", jbControllerAbi],
|
|
811
|
+
["JBMultiTerminal", jbMultiTerminalAbi],
|
|
812
|
+
["JBDirectory", jbDirectoryAbi],
|
|
813
|
+
["JBTokens", jbTokensAbi],
|
|
814
|
+
["JBPermissions", jbPermissionsAbi],
|
|
815
|
+
["JBSplits", jbSplitsAbi],
|
|
816
|
+
["JBProjects", jbProjectsAbi],
|
|
817
|
+
["JBBuybackHookRegistry", jbBuybackHookRegistryAbi],
|
|
818
|
+
["JBBuybackHook", jbBuybackHookAbi],
|
|
819
|
+
["JBRouterTerminalRegistry", jbRouterTerminalRegistryAbi],
|
|
820
|
+
["JBRouterTerminalGateway", jbRouterTerminalGatewayAbi],
|
|
821
|
+
]);
|
|
822
|
+
return safeInnerAbis.get(contract);
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* The call a Safe `execTransaction` makes: `data` sent to `to`. Several of
|
|
826
|
+
* these contracts share selectors (`pay` on the terminal and the router
|
|
827
|
+
* gateway, `approve` on JBProjects and any ERC-20), so the ABI comes from the
|
|
828
|
+
* target. A known Juicebox deployment, a retired generation included, is read
|
|
829
|
+
* with its contract's ABI, and USDC as ERC-20. An unknown target is read as
|
|
830
|
+
* ERC-20 and titled as unrecognized, except for `approve` and `transferFrom`:
|
|
831
|
+
* ERC-721 shares those selectors with a token ID where ERC-20 has an amount,
|
|
832
|
+
* so they are null. Any other target, a selector its ABI lacks, or a
|
|
833
|
+
* non-canonical encoding is null too.
|
|
834
|
+
*/
|
|
835
|
+
export function describeSafeInnerCall(chainId, to, data) {
|
|
836
|
+
if (typeof data !== "string" ||
|
|
837
|
+
!/^0x(?:[0-9a-f]{2})+$/iu.test(data) ||
|
|
838
|
+
data.length < 10) {
|
|
839
|
+
return null;
|
|
840
|
+
}
|
|
841
|
+
if (typeof to !== "string" || !/^0x[0-9a-f]{40}$/iu.test(to))
|
|
842
|
+
return null;
|
|
843
|
+
const target = knownAddressName(chainId, to);
|
|
844
|
+
const abi = target === null || target === "USDC"
|
|
845
|
+
? erc20Abi
|
|
846
|
+
: safeInnerAbiOf(target.replace(/ \([^)]*\)$/u, ""));
|
|
847
|
+
if (!abi)
|
|
848
|
+
return null;
|
|
849
|
+
const selector = data.slice(0, 10).toLowerCase();
|
|
850
|
+
if (target === null &&
|
|
851
|
+
(selector === "0x095ea7b3" || selector === "0x23b872dd")) {
|
|
852
|
+
return null;
|
|
853
|
+
}
|
|
854
|
+
const item = abi.find((entry) => entry.type === "function" && toFunctionSelector(entry) === selector);
|
|
855
|
+
if (!item)
|
|
856
|
+
return null;
|
|
857
|
+
let args;
|
|
858
|
+
try {
|
|
859
|
+
args = decodeFunctionData({ abi: [item], data: data }).args ?? [];
|
|
860
|
+
const canonical = encodeFunctionData({
|
|
861
|
+
abi: [item],
|
|
862
|
+
functionName: item.name,
|
|
863
|
+
args,
|
|
864
|
+
});
|
|
865
|
+
if (canonical.toLowerCase() !== data.toLowerCase())
|
|
866
|
+
return null;
|
|
867
|
+
}
|
|
868
|
+
catch {
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
const rows = args.map((argument, index) => [
|
|
872
|
+
item.inputs[index].name || `argument ${index + 1}`,
|
|
873
|
+
bigintJson(argument),
|
|
874
|
+
]);
|
|
875
|
+
return [
|
|
876
|
+
{
|
|
877
|
+
title: target === null
|
|
878
|
+
? `Queued call — ${item.name}(…) on an unrecognized contract, read as ERC-20`
|
|
879
|
+
: `Queued call — ${target}.${item.name}(…)`,
|
|
880
|
+
rows: rows.length ? rows : [["Arguments", "none"]],
|
|
881
|
+
},
|
|
882
|
+
];
|
|
883
|
+
}
|
|
884
|
+
// ── Safe proxy initializer ───────────────────────────────────────────────────
|
|
885
|
+
const safeSetupAbi = [
|
|
886
|
+
{
|
|
887
|
+
type: "function",
|
|
888
|
+
name: "setup",
|
|
889
|
+
stateMutability: "nonpayable",
|
|
890
|
+
inputs: [
|
|
891
|
+
{ name: "_owners", type: "address[]" },
|
|
892
|
+
{ name: "_threshold", type: "uint256" },
|
|
893
|
+
{ name: "to", type: "address" },
|
|
894
|
+
{ name: "data", type: "bytes" },
|
|
895
|
+
{ name: "fallbackHandler", type: "address" },
|
|
896
|
+
{ name: "paymentToken", type: "address" },
|
|
897
|
+
{ name: "payment", type: "uint256" },
|
|
898
|
+
{ name: "paymentReceiver", type: "address" },
|
|
899
|
+
],
|
|
900
|
+
outputs: [],
|
|
901
|
+
},
|
|
902
|
+
];
|
|
903
|
+
const safeToL2SetupAbi = [
|
|
904
|
+
{
|
|
905
|
+
type: "function",
|
|
906
|
+
name: "setupToL2",
|
|
907
|
+
stateMutability: "nonpayable",
|
|
908
|
+
inputs: [{ name: "l2Singleton", type: "address" }],
|
|
909
|
+
outputs: [],
|
|
910
|
+
},
|
|
911
|
+
];
|
|
912
|
+
/** The canonical SafeToL2Setup deployment, the same address on every chain. */
|
|
913
|
+
const SAFE_TO_L2_SETUP = "0xbd89a1ce4dde368ffab0ec35506eece0b1ffdc54";
|
|
914
|
+
/** A Safe proxy `initializer`: owners, threshold, setup hook and any payment. */
|
|
915
|
+
export function describeSafeInitializer(chainId, value) {
|
|
916
|
+
if (typeof value !== "string" || !value.startsWith("0x"))
|
|
917
|
+
return null;
|
|
918
|
+
const args = (() => {
|
|
919
|
+
try {
|
|
920
|
+
return decodeFunctionData({ abi: safeSetupAbi, data: value }).args;
|
|
921
|
+
}
|
|
922
|
+
catch {
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
})();
|
|
926
|
+
if (!args)
|
|
927
|
+
return null;
|
|
928
|
+
// A noncanonical encoding could make the summary disagree with the bytes.
|
|
929
|
+
const canonical = encodeFunctionData({
|
|
930
|
+
abi: safeSetupAbi,
|
|
931
|
+
functionName: "setup",
|
|
932
|
+
args,
|
|
933
|
+
});
|
|
934
|
+
if (canonical.toLowerCase() !== value.toLowerCase())
|
|
935
|
+
return null;
|
|
936
|
+
const [owners, threshold, to, data, fallbackHandler, paymentToken, payment, paymentReceiver,] = args;
|
|
937
|
+
const rows = [
|
|
938
|
+
["Owners", owners.join(", ") || "none"],
|
|
939
|
+
["Threshold", `${threshold} of ${owners.length}`],
|
|
940
|
+
["Fallback handler", addressLabel(chainId, fallbackHandler)],
|
|
941
|
+
];
|
|
942
|
+
if (to.toLowerCase() === zeroAddress) {
|
|
943
|
+
// Safe runs the setup delegatecall only when `to` is set.
|
|
944
|
+
rows.push([
|
|
945
|
+
"Setup hook",
|
|
946
|
+
data === "0x"
|
|
947
|
+
? "none"
|
|
948
|
+
: "none — Safe ignores the setup data when `to` is zero",
|
|
949
|
+
]);
|
|
950
|
+
}
|
|
951
|
+
else {
|
|
952
|
+
let hook = `DELEGATECALL to ${to} — data in the raw payload below`;
|
|
953
|
+
// Only the canonical deployment is known to run SafeToL2Setup's code; any
|
|
954
|
+
// other target could run anything under the same calldata.
|
|
955
|
+
if (to.toLowerCase() === SAFE_TO_L2_SETUP) {
|
|
956
|
+
try {
|
|
957
|
+
const inner = decodeFunctionData({ abi: safeToL2SetupAbi, data });
|
|
958
|
+
const canonicalInner = encodeFunctionData({
|
|
959
|
+
abi: safeToL2SetupAbi,
|
|
960
|
+
functionName: "setupToL2",
|
|
961
|
+
args: inner.args,
|
|
962
|
+
});
|
|
963
|
+
if (canonicalInner.toLowerCase() === data.toLowerCase()) {
|
|
964
|
+
hook = `SafeToL2Setup.setupToL2(${inner.args[0]}) via ${to}`;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
catch {
|
|
968
|
+
// Keep the generic delegatecall warning.
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
rows.push(["Setup hook", hook]);
|
|
972
|
+
}
|
|
973
|
+
if (payment !== 0n || paymentToken.toLowerCase() !== zeroAddress) {
|
|
974
|
+
rows.push([
|
|
975
|
+
"Deployment payment",
|
|
976
|
+
`${payment} of ${currencyLabel(chainId, paymentToken)} to ${paymentReceiver} — unusual, verify`,
|
|
977
|
+
]);
|
|
978
|
+
}
|
|
979
|
+
return [{ title: "Safe setup", rows }];
|
|
980
|
+
}
|
|
981
|
+
// ── Permission grants ────────────────────────────────────────────────────────
|
|
982
|
+
/** A `setPermissionsFor` grant, naming each permission id from the V6 catalog. */
|
|
983
|
+
export function describePermissionsData(chainId, value) {
|
|
984
|
+
const data = value;
|
|
985
|
+
if (!data ||
|
|
986
|
+
typeof data.operator !== "string" ||
|
|
987
|
+
(typeof data.projectId !== "bigint" &&
|
|
988
|
+
typeof data.projectId !== "number") ||
|
|
989
|
+
!Array.isArray(data.permissionIds) ||
|
|
990
|
+
!data.permissionIds.every((id) => typeof id === "number" && Number.isInteger(id))) {
|
|
991
|
+
return null;
|
|
992
|
+
}
|
|
993
|
+
const permissionIds = data.permissionIds;
|
|
994
|
+
const projectId = BigInt(data.projectId);
|
|
995
|
+
const names = permissionIds.map((id) => {
|
|
996
|
+
const name = permissionKeyV6(id);
|
|
997
|
+
return name ? `${name} (${id})` : `UNKNOWN PERMISSION (${id})`;
|
|
998
|
+
});
|
|
999
|
+
const rows = [
|
|
1000
|
+
["Operator", addressLabel(chainId, data.operator)],
|
|
1001
|
+
[
|
|
1002
|
+
"Scope",
|
|
1003
|
+
projectId === 0n
|
|
1004
|
+
? "project 0 — EVERY project this account ever owns"
|
|
1005
|
+
: `project #${projectId}`,
|
|
1006
|
+
],
|
|
1007
|
+
[
|
|
1008
|
+
"Permissions",
|
|
1009
|
+
names.length
|
|
1010
|
+
? names.join(", ")
|
|
1011
|
+
: "none — revokes everything previously granted",
|
|
1012
|
+
],
|
|
1013
|
+
];
|
|
1014
|
+
if (permissionIds.includes(1)) {
|
|
1015
|
+
rows.push([
|
|
1016
|
+
"Warning",
|
|
1017
|
+
"ROOT grants every permission across all Juicebox contracts",
|
|
1018
|
+
]);
|
|
1019
|
+
}
|
|
1020
|
+
return [{ title: "Set operator permissions", rows }];
|
|
1021
|
+
}
|
|
1022
|
+
// ── Split groups ─────────────────────────────────────────────────────────────
|
|
1023
|
+
function splitPercent(percent) {
|
|
1024
|
+
const share = (percent * 100) / SPLITS_TOTAL_PERCENT;
|
|
1025
|
+
return `${Number(share.toFixed(4))}%`;
|
|
1026
|
+
}
|
|
1027
|
+
/** Whether `hook` is this chain's StickyDistributor (false where Sticky is not deployed). */
|
|
1028
|
+
function isStickyHook(hook, chainId) {
|
|
1029
|
+
try {
|
|
1030
|
+
return isStickySplit({ hook: hook }, chainId);
|
|
1031
|
+
}
|
|
1032
|
+
catch {
|
|
1033
|
+
return false;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* `setSplitGroupsOf` groups: each split's share, recipient, hook and lock,
|
|
1038
|
+
* with an honest total. A Sticky split names the holder group it pays.
|
|
1039
|
+
*/
|
|
1040
|
+
export function describeSplitGroups(chainId, value) {
|
|
1041
|
+
if (!Array.isArray(value))
|
|
1042
|
+
return null;
|
|
1043
|
+
const steps = [];
|
|
1044
|
+
for (const group of value) {
|
|
1045
|
+
if (typeof group?.groupId !== "bigint" || !Array.isArray(group.splits)) {
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
1048
|
+
const groupLabel = group.groupId === 1n
|
|
1049
|
+
? "Reserved tokens"
|
|
1050
|
+
: group.groupId < 1n << 160n
|
|
1051
|
+
? `Payouts of ${addressLabel(chainId, `0x${group.groupId.toString(16).padStart(40, "0")}`)}`
|
|
1052
|
+
: `Group ${group.groupId}`;
|
|
1053
|
+
const rows = [];
|
|
1054
|
+
let total = 0;
|
|
1055
|
+
for (const [index, split] of group.splits.entries()) {
|
|
1056
|
+
if (typeof split?.percent !== "number" ||
|
|
1057
|
+
typeof split.beneficiary !== "string" ||
|
|
1058
|
+
typeof split.projectId !== "bigint" ||
|
|
1059
|
+
(split.lockedUntil !== undefined &&
|
|
1060
|
+
!Number.isSafeInteger(split.lockedUntil))) {
|
|
1061
|
+
return null;
|
|
1062
|
+
}
|
|
1063
|
+
total += split.percent;
|
|
1064
|
+
// A Sticky split's projectId is its holder group and its beneficiary is the Sticky token.
|
|
1065
|
+
const sticky = typeof split.hook === "string" && isStickyHook(split.hook, chainId);
|
|
1066
|
+
const parts = [
|
|
1067
|
+
sticky
|
|
1068
|
+
? `${describeStickySplit({ projectId: split.projectId })} → Sticky token ${addressLabel(chainId, split.beneficiary)}`
|
|
1069
|
+
: split.projectId !== 0n
|
|
1070
|
+
? `project #${split.projectId} (beneficiary ${split.beneficiary})`
|
|
1071
|
+
: addressLabel(chainId, split.beneficiary),
|
|
1072
|
+
];
|
|
1073
|
+
if (sticky) {
|
|
1074
|
+
parts.push(`via StickyDistributor ${split.hook}`);
|
|
1075
|
+
}
|
|
1076
|
+
else if (typeof split.hook === "string" &&
|
|
1077
|
+
split.hook.toLowerCase() !== zeroAddress) {
|
|
1078
|
+
parts.push(`via hook ${split.hook}`);
|
|
1079
|
+
}
|
|
1080
|
+
if (split.preferAddToBalance === true) {
|
|
1081
|
+
parts.push("prefers add-to-balance");
|
|
1082
|
+
}
|
|
1083
|
+
if (typeof split.lockedUntil === "number" && split.lockedUntil > 0) {
|
|
1084
|
+
parts.push(`locked until ${unixTime(split.lockedUntil)}`);
|
|
1085
|
+
}
|
|
1086
|
+
rows.push([
|
|
1087
|
+
`Split ${index + 1} — ${splitPercent(split.percent)}`,
|
|
1088
|
+
parts.join(" | "),
|
|
1089
|
+
]);
|
|
1090
|
+
}
|
|
1091
|
+
rows.push([
|
|
1092
|
+
"Total",
|
|
1093
|
+
`${splitPercent(total)}${total === SPLITS_TOTAL_PERCENT ? "" : " — the remainder follows the ruleset's default"}`,
|
|
1094
|
+
]);
|
|
1095
|
+
steps.push({
|
|
1096
|
+
title: groupLabel,
|
|
1097
|
+
rows: rows.length > 1 ? rows : [["Splits", "none"]],
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
return steps.length ? steps : null;
|
|
1101
|
+
}
|
|
1102
|
+
//# sourceMappingURL=decode.js.map
|