@bananapus/nana-sdk-core 2.16.0 → 2.18.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/constants.d.ts +1 -13
- package/dist/cjs/constants.d.ts.map +1 -1
- package/dist/cjs/constants.js +4 -22
- package/dist/cjs/constants.js.map +1 -1
- package/dist/cjs/generated/abi/jbBuybackHookAbi.d.ts +1187 -0
- package/dist/cjs/generated/abi/jbBuybackHookAbi.d.ts.map +1 -0
- package/dist/cjs/generated/abi/jbBuybackHookAbi.js +841 -0
- package/dist/cjs/generated/abi/jbBuybackHookAbi.js.map +1 -0
- package/dist/cjs/generated/abi/jbBuybackHookRegistryAbi.d.ts +791 -0
- package/dist/cjs/generated/abi/jbBuybackHookRegistryAbi.d.ts.map +1 -0
- package/dist/cjs/generated/abi/jbBuybackHookRegistryAbi.js +632 -0
- package/dist/cjs/generated/abi/jbBuybackHookRegistryAbi.js.map +1 -0
- package/dist/cjs/generated/abi/jbControllerAbi.d.ts +2569 -0
- package/dist/cjs/generated/abi/jbControllerAbi.d.ts.map +1 -0
- package/dist/cjs/generated/abi/jbControllerAbi.js +1906 -0
- package/dist/cjs/generated/abi/jbControllerAbi.js.map +1 -0
- package/dist/cjs/generated/abi/jbDirectoryAbi.d.ts +410 -0
- package/dist/cjs/generated/abi/jbDirectoryAbi.d.ts.map +1 -0
- package/dist/cjs/generated/abi/jbDirectoryAbi.js +365 -0
- package/dist/cjs/generated/abi/jbDirectoryAbi.js.map +1 -0
- package/dist/cjs/generated/abi/jbMultiTerminalAbi.d.ts +1863 -0
- package/dist/cjs/generated/abi/jbMultiTerminalAbi.d.ts.map +1 -0
- package/dist/cjs/generated/abi/jbMultiTerminalAbi.js +1459 -0
- package/dist/cjs/generated/abi/jbMultiTerminalAbi.js.map +1 -0
- package/dist/cjs/generated/abi/jbPermissionsAbi.d.ts +240 -0
- package/dist/cjs/generated/abi/jbPermissionsAbi.d.ts.map +1 -0
- package/dist/cjs/generated/abi/jbPermissionsAbi.js +164 -0
- package/dist/cjs/generated/abi/jbPermissionsAbi.js.map +1 -0
- package/dist/cjs/generated/abi/jbProjectsAbi.d.ts +626 -0
- package/dist/cjs/generated/abi/jbProjectsAbi.d.ts.map +1 -0
- package/dist/cjs/generated/abi/jbProjectsAbi.js +470 -0
- package/dist/cjs/generated/abi/jbProjectsAbi.js.map +1 -0
- package/dist/cjs/generated/abi/jbRouterTerminalGatewayAbi.d.ts +1503 -0
- package/dist/cjs/generated/abi/jbRouterTerminalGatewayAbi.d.ts.map +1 -0
- package/dist/cjs/generated/abi/jbRouterTerminalGatewayAbi.js +1037 -0
- package/dist/cjs/generated/abi/jbRouterTerminalGatewayAbi.js.map +1 -0
- package/dist/cjs/generated/abi/jbRouterTerminalRegistryAbi.d.ts +1119 -0
- package/dist/cjs/generated/abi/jbRouterTerminalRegistryAbi.d.ts.map +1 -0
- package/dist/cjs/generated/abi/jbRouterTerminalRegistryAbi.js +925 -0
- package/dist/cjs/generated/abi/jbRouterTerminalRegistryAbi.js.map +1 -0
- package/dist/cjs/generated/abi/jbSplitsAbi.d.ts +244 -0
- package/dist/cjs/generated/abi/jbSplitsAbi.d.ts.map +1 -0
- package/dist/cjs/generated/abi/jbSplitsAbi.js +198 -0
- package/dist/cjs/generated/abi/jbSplitsAbi.js.map +1 -0
- package/dist/cjs/generated/abi/jbTokensAbi.d.ts +602 -0
- package/dist/cjs/generated/abi/jbTokensAbi.d.ts.map +1 -0
- package/dist/cjs/generated/abi/jbTokensAbi.js +501 -0
- package/dist/cjs/generated/abi/jbTokensAbi.js.map +1 -0
- package/dist/cjs/generated/juicebox.d.ts +25405 -36537
- package/dist/cjs/generated/juicebox.d.ts.map +1 -1
- package/dist/cjs/generated/juicebox.js +14362 -22761
- package/dist/cjs/generated/juicebox.js.map +1 -1
- package/dist/cjs/pureConstants.d.ts +15 -0
- package/dist/cjs/pureConstants.d.ts.map +1 -0
- package/dist/cjs/pureConstants.js +28 -0
- package/dist/cjs/pureConstants.js.map +1 -0
- package/dist/cjs/review/decode.d.ts.map +1 -1
- package/dist/cjs/review/decode.js +35 -51
- package/dist/cjs/review/decode.js.map +1 -1
- package/dist/cjs/review/relayr.d.ts +380 -0
- package/dist/cjs/review/relayr.d.ts.map +1 -0
- package/dist/cjs/review/relayr.js +899 -0
- package/dist/cjs/review/relayr.js.map +1 -0
- package/dist/cjs/safe.d.ts +376 -8
- package/dist/cjs/safe.d.ts.map +1 -1
- package/dist/cjs/safe.js +1088 -29
- package/dist/cjs/safe.js.map +1 -1
- package/dist/cjs/safeService.d.ts +361 -1
- package/dist/cjs/safeService.d.ts.map +1 -1
- package/dist/cjs/safeService.js +844 -6
- package/dist/cjs/safeService.js.map +1 -1
- package/dist/cjs/untrusted.d.ts +8 -0
- package/dist/cjs/untrusted.d.ts.map +1 -0
- package/dist/cjs/untrusted.js +33 -0
- package/dist/cjs/untrusted.js.map +1 -0
- package/dist/cjs/v6/distributions.d.ts +90 -0
- package/dist/cjs/v6/distributions.d.ts.map +1 -0
- package/dist/cjs/v6/distributions.js +287 -0
- package/dist/cjs/v6/distributions.js.map +1 -0
- package/dist/cjs/v6/index.d.ts +1 -0
- package/dist/cjs/v6/index.d.ts.map +1 -1
- package/dist/cjs/v6/index.js +1 -0
- package/dist/cjs/v6/index.js.map +1 -1
- package/dist/cjs/v6/permissionIds.d.ts +62 -0
- package/dist/cjs/v6/permissionIds.d.ts.map +1 -0
- package/dist/cjs/v6/permissionIds.js +70 -0
- package/dist/cjs/v6/permissionIds.js.map +1 -0
- package/dist/cjs/v6/permissions.d.ts +3 -62
- package/dist/cjs/v6/permissions.d.ts.map +1 -1
- package/dist/cjs/v6/permissions.js +6 -67
- package/dist/cjs/v6/permissions.js.map +1 -1
- package/dist/cjs/v6/splits.d.ts +1 -1
- package/dist/cjs/v6/splits.d.ts.map +1 -1
- package/dist/cjs/v6/splits.js +3 -3
- package/dist/cjs/v6/splits.js.map +1 -1
- package/dist/cjs/v6/sticky.d.ts +2 -2
- package/dist/cjs/v6/sticky.d.ts.map +1 -1
- package/dist/cjs/v6/types.d.ts +1 -1
- package/dist/cjs/v6/types.d.ts.map +1 -1
- package/dist/esm/constants.d.ts +1 -13
- package/dist/esm/constants.d.ts.map +1 -1
- package/dist/esm/constants.js +1 -21
- package/dist/esm/constants.js.map +1 -1
- package/dist/esm/generated/abi/jbBuybackHookAbi.d.ts +1187 -0
- package/dist/esm/generated/abi/jbBuybackHookAbi.d.ts.map +1 -0
- package/dist/esm/generated/abi/jbBuybackHookAbi.js +838 -0
- package/dist/esm/generated/abi/jbBuybackHookAbi.js.map +1 -0
- package/dist/esm/generated/abi/jbBuybackHookRegistryAbi.d.ts +791 -0
- package/dist/esm/generated/abi/jbBuybackHookRegistryAbi.d.ts.map +1 -0
- package/dist/esm/generated/abi/jbBuybackHookRegistryAbi.js +629 -0
- package/dist/esm/generated/abi/jbBuybackHookRegistryAbi.js.map +1 -0
- package/dist/esm/generated/abi/jbControllerAbi.d.ts +2569 -0
- package/dist/esm/generated/abi/jbControllerAbi.d.ts.map +1 -0
- package/dist/esm/generated/abi/jbControllerAbi.js +1903 -0
- package/dist/esm/generated/abi/jbControllerAbi.js.map +1 -0
- package/dist/esm/generated/abi/jbDirectoryAbi.d.ts +410 -0
- package/dist/esm/generated/abi/jbDirectoryAbi.d.ts.map +1 -0
- package/dist/esm/generated/abi/jbDirectoryAbi.js +362 -0
- package/dist/esm/generated/abi/jbDirectoryAbi.js.map +1 -0
- package/dist/esm/generated/abi/jbMultiTerminalAbi.d.ts +1863 -0
- package/dist/esm/generated/abi/jbMultiTerminalAbi.d.ts.map +1 -0
- package/dist/esm/generated/abi/jbMultiTerminalAbi.js +1456 -0
- package/dist/esm/generated/abi/jbMultiTerminalAbi.js.map +1 -0
- package/dist/esm/generated/abi/jbPermissionsAbi.d.ts +240 -0
- package/dist/esm/generated/abi/jbPermissionsAbi.d.ts.map +1 -0
- package/dist/esm/generated/abi/jbPermissionsAbi.js +161 -0
- package/dist/esm/generated/abi/jbPermissionsAbi.js.map +1 -0
- package/dist/esm/generated/abi/jbProjectsAbi.d.ts +626 -0
- package/dist/esm/generated/abi/jbProjectsAbi.d.ts.map +1 -0
- package/dist/esm/generated/abi/jbProjectsAbi.js +467 -0
- package/dist/esm/generated/abi/jbProjectsAbi.js.map +1 -0
- package/dist/esm/generated/abi/jbRouterTerminalGatewayAbi.d.ts +1503 -0
- package/dist/esm/generated/abi/jbRouterTerminalGatewayAbi.d.ts.map +1 -0
- package/dist/esm/generated/abi/jbRouterTerminalGatewayAbi.js +1034 -0
- package/dist/esm/generated/abi/jbRouterTerminalGatewayAbi.js.map +1 -0
- package/dist/esm/generated/abi/jbRouterTerminalRegistryAbi.d.ts +1119 -0
- package/dist/esm/generated/abi/jbRouterTerminalRegistryAbi.d.ts.map +1 -0
- package/dist/esm/generated/abi/jbRouterTerminalRegistryAbi.js +922 -0
- package/dist/esm/generated/abi/jbRouterTerminalRegistryAbi.js.map +1 -0
- package/dist/esm/generated/abi/jbSplitsAbi.d.ts +244 -0
- package/dist/esm/generated/abi/jbSplitsAbi.d.ts.map +1 -0
- package/dist/esm/generated/abi/jbSplitsAbi.js +195 -0
- package/dist/esm/generated/abi/jbSplitsAbi.js.map +1 -0
- package/dist/esm/generated/abi/jbTokensAbi.d.ts +602 -0
- package/dist/esm/generated/abi/jbTokensAbi.d.ts.map +1 -0
- package/dist/esm/generated/abi/jbTokensAbi.js +498 -0
- package/dist/esm/generated/abi/jbTokensAbi.js.map +1 -0
- package/dist/esm/generated/juicebox.d.ts +25405 -36537
- package/dist/esm/generated/juicebox.d.ts.map +1 -1
- package/dist/esm/generated/juicebox.js +14634 -23044
- package/dist/esm/generated/juicebox.js.map +1 -1
- package/dist/esm/pureConstants.d.ts +15 -0
- package/dist/esm/pureConstants.d.ts.map +1 -0
- package/dist/esm/pureConstants.js +25 -0
- package/dist/esm/pureConstants.js.map +1 -0
- package/dist/esm/review/decode.d.ts.map +1 -1
- package/dist/esm/review/decode.js +21 -37
- package/dist/esm/review/decode.js.map +1 -1
- package/dist/esm/review/relayr.d.ts +380 -0
- package/dist/esm/review/relayr.d.ts.map +1 -0
- package/dist/esm/review/relayr.js +874 -0
- package/dist/esm/review/relayr.js.map +1 -0
- package/dist/esm/safe.d.ts +376 -8
- package/dist/esm/safe.d.ts.map +1 -1
- package/dist/esm/safe.js +1070 -29
- package/dist/esm/safe.js.map +1 -1
- package/dist/esm/safeService.d.ts +361 -1
- package/dist/esm/safeService.d.ts.map +1 -1
- package/dist/esm/safeService.js +818 -5
- package/dist/esm/safeService.js.map +1 -1
- package/dist/esm/untrusted.d.ts +8 -0
- package/dist/esm/untrusted.d.ts.map +1 -0
- package/dist/esm/untrusted.js +28 -0
- package/dist/esm/untrusted.js.map +1 -0
- package/dist/esm/v6/distributions.d.ts +90 -0
- package/dist/esm/v6/distributions.d.ts.map +1 -0
- package/dist/esm/v6/distributions.js +283 -0
- package/dist/esm/v6/distributions.js.map +1 -0
- package/dist/esm/v6/index.d.ts +1 -0
- package/dist/esm/v6/index.d.ts.map +1 -1
- package/dist/esm/v6/index.js +1 -0
- package/dist/esm/v6/index.js.map +1 -1
- package/dist/esm/v6/permissionIds.d.ts +62 -0
- package/dist/esm/v6/permissionIds.d.ts.map +1 -0
- package/dist/esm/v6/permissionIds.js +66 -0
- package/dist/esm/v6/permissionIds.js.map +1 -0
- package/dist/esm/v6/permissions.d.ts +3 -62
- package/dist/esm/v6/permissions.d.ts.map +1 -1
- package/dist/esm/v6/permissions.js +2 -64
- package/dist/esm/v6/permissions.js.map +1 -1
- package/dist/esm/v6/splits.d.ts +1 -1
- package/dist/esm/v6/splits.d.ts.map +1 -1
- package/dist/esm/v6/splits.js +1 -1
- package/dist/esm/v6/splits.js.map +1 -1
- package/dist/esm/v6/sticky.d.ts +2 -2
- package/dist/esm/v6/sticky.d.ts.map +1 -1
- package/dist/esm/v6/types.d.ts +1 -1
- package/dist/esm/v6/types.d.ts.map +1 -1
- package/package.json +6 -1
package/dist/esm/safeService.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { getAddress, hashTypedData, isAddress, isAddressEqual, zeroAddress, } from "viem";
|
|
2
|
+
import { encodeMultiSend, isRecognizedSafeDeployment, MULTI_SEND_CALL_ONLY, } from "./safe.js";
|
|
3
|
+
import { isBytes32, isHexBytes, uint256 } from "./untrusted.js";
|
|
1
4
|
/**
|
|
2
5
|
* Safe's per-chain app URL prefix. Wider than {@link SAFE_SERVICE_PREFIX}:
|
|
3
6
|
* Safe app links work on chains without a hosted transaction service.
|
|
@@ -85,6 +88,9 @@ export function isSafeWalletPeer(url) {
|
|
|
85
88
|
function noHostedService(chainId) {
|
|
86
89
|
return new Error(`Safe does not host a transaction service on chain ${chainId}, so this proposal cannot be tracked here. Execute it from the Safe app; the action takes effect once it is executed there.`);
|
|
87
90
|
}
|
|
91
|
+
/** A service answer the wait cannot get past: it ends the wait, unlike a network error. */
|
|
92
|
+
class SafeExecutionRecordError extends Error {
|
|
93
|
+
}
|
|
88
94
|
/**
|
|
89
95
|
* Resolve a Safe proposal identifier to its actual onchain execution hash.
|
|
90
96
|
* A safeTxHash is not a transaction hash and must never be receipt-polled.
|
|
@@ -99,6 +105,9 @@ function noHostedService(chainId) {
|
|
|
99
105
|
* the execution's own hash when the owner executes at once.
|
|
100
106
|
*/
|
|
101
107
|
export async function waitForSafeExecutionHash(chainId, safeTxHash, options = {}) {
|
|
108
|
+
if (!isBytes32(safeTxHash)) {
|
|
109
|
+
throw new Error(`Invalid Safe proposal hash: ${String(safeTxHash)}.`);
|
|
110
|
+
}
|
|
102
111
|
const base = safeServiceBase(chainId);
|
|
103
112
|
const { client } = options;
|
|
104
113
|
if (!base && !client)
|
|
@@ -127,24 +136,25 @@ export async function waitForSafeExecutionHash(chainId, safeTxHash, options = {}
|
|
|
127
136
|
consecutiveNotFound = 0;
|
|
128
137
|
const transaction = (await response.json());
|
|
129
138
|
if (transaction.isExecuted && transaction.isSuccessful === false) {
|
|
130
|
-
throw new
|
|
139
|
+
throw new SafeExecutionRecordError("Safe executed the proposal, but the onchain transaction failed.");
|
|
131
140
|
}
|
|
132
141
|
if (transaction.isExecuted && transaction.transactionHash) {
|
|
142
|
+
if (!isBytes32(transaction.transactionHash)) {
|
|
143
|
+
throw new SafeExecutionRecordError(`Safe’s transaction service reported ${safeTxHash} executed in a malformed transaction hash: ${String(transaction.transactionHash)}. Check the proposal in the Safe app.`);
|
|
144
|
+
}
|
|
133
145
|
return transaction.transactionHash;
|
|
134
146
|
}
|
|
135
147
|
}
|
|
136
148
|
else if (response.status === 404) {
|
|
137
149
|
consecutiveNotFound += 1;
|
|
138
150
|
if (consecutiveNotFound >= SAFE_EXECUTION_NOT_FOUND_LIMIT) {
|
|
139
|
-
throw new
|
|
151
|
+
throw new SafeExecutionRecordError("Safe’s transaction service has no record of this proposal. Tracking cannot continue here — check the proposal in the Safe app; if it exists there, it will still take effect once executed.");
|
|
140
152
|
}
|
|
141
153
|
}
|
|
142
154
|
}
|
|
143
155
|
catch (error) {
|
|
144
|
-
if (error instanceof
|
|
145
|
-
/executed the proposal.*failed|no record of this proposal/i.test(error.message)) {
|
|
156
|
+
if (error instanceof SafeExecutionRecordError)
|
|
146
157
|
throw error;
|
|
147
|
-
}
|
|
148
158
|
// Other service/network failures are transient. Keep the already-created
|
|
149
159
|
// proposal pending instead of inviting a duplicate submission.
|
|
150
160
|
}
|
|
@@ -164,4 +174,807 @@ export async function waitForSafeExecutionHash(chainId, safeTxHash, options = {}
|
|
|
164
174
|
});
|
|
165
175
|
}
|
|
166
176
|
}
|
|
177
|
+
// ── Safe transactions ────────────────────────────────────────────────────────
|
|
178
|
+
/** The EIP-712 types a Safe transaction is signed and hashed with. */
|
|
179
|
+
export const SAFE_TX_TYPES = {
|
|
180
|
+
SafeTx: [
|
|
181
|
+
{ name: "to", type: "address" },
|
|
182
|
+
{ name: "value", type: "uint256" },
|
|
183
|
+
{ name: "data", type: "bytes" },
|
|
184
|
+
{ name: "operation", type: "uint8" },
|
|
185
|
+
{ name: "safeTxGas", type: "uint256" },
|
|
186
|
+
{ name: "baseGas", type: "uint256" },
|
|
187
|
+
{ name: "gasPrice", type: "uint256" },
|
|
188
|
+
{ name: "gasToken", type: "address" },
|
|
189
|
+
{ name: "refundReceiver", type: "address" },
|
|
190
|
+
{ name: "nonce", type: "uint256" },
|
|
191
|
+
],
|
|
192
|
+
};
|
|
193
|
+
/** `execTransaction` and `approveHash`, and the execution events of Safe 1.4. */
|
|
194
|
+
export const SAFE_EXEC_ABI = [
|
|
195
|
+
{
|
|
196
|
+
type: "function",
|
|
197
|
+
name: "execTransaction",
|
|
198
|
+
stateMutability: "payable",
|
|
199
|
+
inputs: [
|
|
200
|
+
{ name: "to", type: "address" },
|
|
201
|
+
{ name: "value", type: "uint256" },
|
|
202
|
+
{ name: "data", type: "bytes" },
|
|
203
|
+
{ name: "operation", type: "uint8" },
|
|
204
|
+
{ name: "safeTxGas", type: "uint256" },
|
|
205
|
+
{ name: "baseGas", type: "uint256" },
|
|
206
|
+
{ name: "gasPrice", type: "uint256" },
|
|
207
|
+
{ name: "gasToken", type: "address" },
|
|
208
|
+
{ name: "refundReceiver", type: "address" },
|
|
209
|
+
{ name: "signatures", type: "bytes" },
|
|
210
|
+
],
|
|
211
|
+
outputs: [{ name: "success", type: "bool" }],
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
type: "function",
|
|
215
|
+
name: "approveHash",
|
|
216
|
+
stateMutability: "nonpayable",
|
|
217
|
+
inputs: [{ name: "hashToApprove", type: "bytes32" }],
|
|
218
|
+
outputs: [],
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
type: "event",
|
|
222
|
+
name: "ExecutionSuccess",
|
|
223
|
+
anonymous: false,
|
|
224
|
+
inputs: [
|
|
225
|
+
{ name: "txHash", type: "bytes32", indexed: true },
|
|
226
|
+
{ name: "payment", type: "uint256", indexed: false },
|
|
227
|
+
],
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
type: "event",
|
|
231
|
+
name: "ExecutionFailure",
|
|
232
|
+
anonymous: false,
|
|
233
|
+
inputs: [
|
|
234
|
+
{ name: "txHash", type: "bytes32", indexed: true },
|
|
235
|
+
{ name: "payment", type: "uint256", indexed: false },
|
|
236
|
+
],
|
|
237
|
+
},
|
|
238
|
+
];
|
|
239
|
+
function invalidField(name, value) {
|
|
240
|
+
return new Error(`The Safe transaction's ${name} is invalid: ${String(value)}.`);
|
|
241
|
+
}
|
|
242
|
+
function fieldAddress(name, value) {
|
|
243
|
+
if (typeof value !== "string" || !isAddress(value)) {
|
|
244
|
+
throw invalidField(name, value);
|
|
245
|
+
}
|
|
246
|
+
return getAddress(value);
|
|
247
|
+
}
|
|
248
|
+
function fieldUint(name, value) {
|
|
249
|
+
const parsed = uint256(value);
|
|
250
|
+
if (parsed === null)
|
|
251
|
+
throw invalidField(name, value);
|
|
252
|
+
return parsed;
|
|
253
|
+
}
|
|
254
|
+
/** A nonce as a number: apps order and compare them. */
|
|
255
|
+
function nonceOf(value) {
|
|
256
|
+
const parsed = uint256(value);
|
|
257
|
+
if (parsed === null || parsed > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
258
|
+
throw invalidField("nonce", value);
|
|
259
|
+
}
|
|
260
|
+
return Number(parsed);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* The exact EIP-712 message of `tx`. Every field is required and read
|
|
264
|
+
* strictly: addresses checksummed, amounts as uint256 in any form, operation 0
|
|
265
|
+
* (CALL) or 1 (DELEGATECALL), null data as empty, and a null gas token or
|
|
266
|
+
* refund receiver as the zero address. Throws naming the first field that is
|
|
267
|
+
* not.
|
|
268
|
+
*/
|
|
269
|
+
export function safeTransactionMessage(tx) {
|
|
270
|
+
if (!tx || typeof tx !== "object")
|
|
271
|
+
throw invalidField("record", tx);
|
|
272
|
+
const operation = fieldUint("operation", tx.operation);
|
|
273
|
+
if (operation > 1n)
|
|
274
|
+
throw invalidField("operation", tx.operation);
|
|
275
|
+
const data = tx.data ?? "0x";
|
|
276
|
+
if (!isHexBytes(data))
|
|
277
|
+
throw invalidField("data", tx.data);
|
|
278
|
+
return {
|
|
279
|
+
to: fieldAddress("to", tx.to),
|
|
280
|
+
value: fieldUint("value", tx.value),
|
|
281
|
+
data,
|
|
282
|
+
operation: operation === 1n ? 1 : 0,
|
|
283
|
+
safeTxGas: fieldUint("safeTxGas", tx.safeTxGas),
|
|
284
|
+
baseGas: fieldUint("baseGas", tx.baseGas),
|
|
285
|
+
gasPrice: fieldUint("gasPrice", tx.gasPrice),
|
|
286
|
+
// The service stores an omitted gas token or refund receiver as null and
|
|
287
|
+
// hashes it as the zero address, as Safe does; nothing else may be absent.
|
|
288
|
+
gasToken: tx.gasToken === null
|
|
289
|
+
? zeroAddress
|
|
290
|
+
: fieldAddress("gasToken", tx.gasToken),
|
|
291
|
+
refundReceiver: tx.refundReceiver === null
|
|
292
|
+
? zeroAddress
|
|
293
|
+
: fieldAddress("refundReceiver", tx.refundReceiver),
|
|
294
|
+
nonce: BigInt(nonceOf(tx.nonce)),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
function requireChainId(chainId) {
|
|
298
|
+
if (!Number.isSafeInteger(chainId) || chainId <= 0) {
|
|
299
|
+
throw new Error(`Invalid chain ID: ${String(chainId)}.`);
|
|
300
|
+
}
|
|
301
|
+
return chainId;
|
|
302
|
+
}
|
|
303
|
+
function requireSafe(safe) {
|
|
304
|
+
if (typeof safe !== "string" || !isAddress(safe)) {
|
|
305
|
+
throw new Error(`Invalid Safe address: ${String(safe)}.`);
|
|
306
|
+
}
|
|
307
|
+
return getAddress(safe);
|
|
308
|
+
}
|
|
309
|
+
/** The EIP-712 hash `safe` signs and executes `tx` under on `chainId`. */
|
|
310
|
+
export function safeTransactionHash(chainId, safe, tx) {
|
|
311
|
+
return hashTypedData({
|
|
312
|
+
domain: {
|
|
313
|
+
chainId: requireChainId(chainId),
|
|
314
|
+
verifyingContract: requireSafe(safe),
|
|
315
|
+
},
|
|
316
|
+
types: SAFE_TX_TYPES,
|
|
317
|
+
primaryType: "SafeTx",
|
|
318
|
+
message: safeTransactionMessage(tx),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* The hash of `tx`'s exact fields, refusing a record that names another Safe,
|
|
323
|
+
* advertises a different `safeTxHash` or `contractTransactionHash`, or, given
|
|
324
|
+
* `expected`, no longer hashes to the reviewed hash.
|
|
325
|
+
*/
|
|
326
|
+
export function canonicalSafeTxHash(chainId, safe, tx, expected) {
|
|
327
|
+
const address = requireSafe(safe);
|
|
328
|
+
if (tx?.safe !== undefined &&
|
|
329
|
+
tx.safe !== null &&
|
|
330
|
+
(typeof tx.safe !== "string" ||
|
|
331
|
+
!isAddress(tx.safe) ||
|
|
332
|
+
!isAddressEqual(tx.safe, address))) {
|
|
333
|
+
throw new Error(`The Safe transaction belongs to ${String(tx.safe)}, not ${address}.`);
|
|
334
|
+
}
|
|
335
|
+
const computed = safeTransactionHash(chainId, address, tx);
|
|
336
|
+
for (const [name, advertised] of [
|
|
337
|
+
["safeTxHash", tx.safeTxHash],
|
|
338
|
+
["contractTransactionHash", tx.contractTransactionHash],
|
|
339
|
+
]) {
|
|
340
|
+
if (advertised === undefined || advertised === null)
|
|
341
|
+
continue;
|
|
342
|
+
if (!isBytes32(advertised) ||
|
|
343
|
+
advertised.toLowerCase() !== computed.toLowerCase()) {
|
|
344
|
+
throw new Error(`The Safe transaction's ${name} ${String(advertised)} does not match its fields, which hash to ${computed}.`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (expected !== undefined && !isBytes32(expected)) {
|
|
348
|
+
throw new Error(`Invalid reviewed Safe transaction hash: ${String(expected)}.`);
|
|
349
|
+
}
|
|
350
|
+
if (expected !== undefined &&
|
|
351
|
+
expected.toLowerCase() !== computed.toLowerCase()) {
|
|
352
|
+
throw new Error(`The Safe transaction changed: it hashes to ${computed}, not the reviewed ${expected}.`);
|
|
353
|
+
}
|
|
354
|
+
return computed;
|
|
355
|
+
}
|
|
356
|
+
// ── Execution results ────────────────────────────────────────────────────────
|
|
357
|
+
/** `ExecutionSuccess(bytes32,uint256)` and `ExecutionFailure(bytes32,uint256)`, the same in Safe 1.3 and 1.4. */
|
|
358
|
+
const EXECUTION_SUCCESS_TOPIC = "0x442e715f626346e8c54381002da614f62bee8d27386535b2521ec8540898556e";
|
|
359
|
+
const EXECUTION_FAILURE_TOPIC = "0x23428b18acfb3ea64b08dc0c1d296ea9c09702c09083ca5272e64d115b687d23";
|
|
360
|
+
const ZERO_WORD = "0000000000000000000000000000000000000000000000000000000000000000";
|
|
361
|
+
/**
|
|
362
|
+
* A Safe execution event in exactly the Safe 1.4 layout (txHash indexed) or
|
|
363
|
+
* the Safe 1.3 layout (txHash the first data word), with its payment. Null for
|
|
364
|
+
* another event; a string naming what is wrong for a malformed one.
|
|
365
|
+
*/
|
|
366
|
+
function safeExecutionEvent(log) {
|
|
367
|
+
const topics = Array.isArray(log.topics) ? log.topics : [];
|
|
368
|
+
const topic = typeof topics[0] === "string" ? topics[0].toLowerCase() : "";
|
|
369
|
+
if (topic !== EXECUTION_SUCCESS_TOPIC && topic !== EXECUTION_FAILURE_TOPIC) {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
const failed = topic === EXECUTION_FAILURE_TOPIC;
|
|
373
|
+
const name = failed ? "ExecutionFailure" : "ExecutionSuccess";
|
|
374
|
+
const data = typeof log.data === "string" ? log.data.toLowerCase() : "";
|
|
375
|
+
if (topics.length === 2) {
|
|
376
|
+
if (!isBytes32(topics[1]) || !/^0x[\da-f]{64}$/.test(data)) {
|
|
377
|
+
return `a malformed ${name} event`;
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
failed,
|
|
381
|
+
txHash: topics[1].toLowerCase(),
|
|
382
|
+
payment: BigInt(data),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
if (topics.length === 1) {
|
|
386
|
+
if (!/^0x[\da-f]{128}$/.test(data))
|
|
387
|
+
return `a malformed ${name} event`;
|
|
388
|
+
return {
|
|
389
|
+
failed,
|
|
390
|
+
txHash: data.slice(0, 66),
|
|
391
|
+
payment: BigInt(`0x${data.slice(66)}`),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
return `a malformed ${name} event`;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* What `receipt` proves about the Safe transaction `hash` on `safe`. `hash` is
|
|
398
|
+
* what the wallet returned: the safeTxHash, or, when Safe{Wallet} executed the
|
|
399
|
+
* proposal at once, the execution's own transaction hash. Then the receipt
|
|
400
|
+
* must carry exactly one execution event of `safe`, whatever its hash.
|
|
401
|
+
* Otherwise exactly one event for `hash`, so another proposal in the same
|
|
402
|
+
* batch cannot stand in for this one, and another proposal's event, refund
|
|
403
|
+
* included, never decides this one. Any malformed execution event of `safe`
|
|
404
|
+
* leaves the result unproven, since it cannot be told apart from this one.
|
|
405
|
+
*
|
|
406
|
+
* `receipt` is viem's parsed receipt: a raw RPC receipt (status "0x1") reads
|
|
407
|
+
* unproven.
|
|
408
|
+
*
|
|
409
|
+
* The at-once reading proves only that the Safe ran one transaction in that
|
|
410
|
+
* receipt. A caller who holds the executing transaction's input should also
|
|
411
|
+
* bind it to the reviewed call, as an `execTransaction` with exactly the
|
|
412
|
+
* reviewed fields.
|
|
413
|
+
*/
|
|
414
|
+
export function safeExecutionResult(receipt, safe, hash) {
|
|
415
|
+
const address = requireSafe(safe).toLowerCase();
|
|
416
|
+
if (!isBytes32(hash)) {
|
|
417
|
+
throw new Error(`Invalid Safe transaction hash: ${String(hash)}.`);
|
|
418
|
+
}
|
|
419
|
+
if (receipt?.status === "reverted")
|
|
420
|
+
return { status: "reverted" };
|
|
421
|
+
const unproven = (reason) => ({
|
|
422
|
+
status: "unproven",
|
|
423
|
+
reason,
|
|
424
|
+
});
|
|
425
|
+
if (receipt?.status !== "success" || !Array.isArray(receipt.logs)) {
|
|
426
|
+
return unproven(`The receipt has no success status and logs to prove ${hash} on Safe ${safe}.`);
|
|
427
|
+
}
|
|
428
|
+
const atOnce = typeof receipt.transactionHash === "string" &&
|
|
429
|
+
receipt.transactionHash.toLowerCase() === hash.toLowerCase();
|
|
430
|
+
const events = [];
|
|
431
|
+
for (const log of receipt.logs) {
|
|
432
|
+
if (typeof log?.address !== "string" ||
|
|
433
|
+
log.address.toLowerCase() !== address) {
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
const event = safeExecutionEvent(log);
|
|
437
|
+
if (typeof event === "string") {
|
|
438
|
+
return unproven(`Safe ${safe} logged ${event}.`);
|
|
439
|
+
}
|
|
440
|
+
if (event && (atOnce || event.txHash === hash.toLowerCase())) {
|
|
441
|
+
events.push(event);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const subject = atOnce ? `transaction ${hash}` : `Safe transaction ${hash}`;
|
|
445
|
+
if (events.length === 0) {
|
|
446
|
+
return unproven(`The receipt has no ExecutionSuccess or ExecutionFailure from Safe ${safe} for ${subject}.`);
|
|
447
|
+
}
|
|
448
|
+
if (events.length > 1) {
|
|
449
|
+
return unproven(`Safe ${safe} logged ${events.length} execution results (${events.map((event) => (event.failed ? "ExecutionFailure" : "ExecutionSuccess")).join(", ")}) for ${subject}, so the receipt proves none of them.`);
|
|
450
|
+
}
|
|
451
|
+
const [event] = events;
|
|
452
|
+
return {
|
|
453
|
+
status: event.failed ? "failed" : "success",
|
|
454
|
+
payment: event.payment,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
/** Throws unless `receipt` proves the Safe transaction `hash` ran and succeeded ({@link safeExecutionResult}). */
|
|
458
|
+
export function requireSafeExecutionSuccess(receipt, safe, hash) {
|
|
459
|
+
const result = safeExecutionResult(receipt, safe, hash);
|
|
460
|
+
if (result.status === "success")
|
|
461
|
+
return;
|
|
462
|
+
if (result.status === "reverted") {
|
|
463
|
+
throw new Error(`The transaction executing ${hash} on Safe ${safe} reverted, so the Safe ran nothing.`);
|
|
464
|
+
}
|
|
465
|
+
if (result.status === "failed") {
|
|
466
|
+
throw new Error(`Safe ${safe} ran ${hash}, but its call failed (ExecutionFailure).`);
|
|
467
|
+
}
|
|
468
|
+
throw new Error(result.reason);
|
|
469
|
+
}
|
|
470
|
+
// ── Signatures ───────────────────────────────────────────────────────────────
|
|
471
|
+
/**
|
|
472
|
+
* One confirmation's part of Safe's `signatures` bytes, without 0x:
|
|
473
|
+
*
|
|
474
|
+
* - none at all: the `approveHash` form (v = 1) naming the owner, a head;
|
|
475
|
+
* - an ECDSA, eth_sign or approved-hash signature: exactly 65 bytes, a head;
|
|
476
|
+
* an approved hash (v = 1) must name its own owner;
|
|
477
|
+
* - an EIP-1271 contract signature in the transaction service's standalone
|
|
478
|
+
* form: r = owner, s = 65, v = 0, then a length word and the signature,
|
|
479
|
+
* optionally zero-padded to a word. Its dynamic part becomes a tail that
|
|
480
|
+
* {@link safeExecutionSignatures} places after every head.
|
|
481
|
+
*
|
|
482
|
+
* Null for anything else, which never counts as a confirmation.
|
|
483
|
+
*/
|
|
484
|
+
function signaturePart(owner, signature) {
|
|
485
|
+
const word = owner.slice(2).toLowerCase().padStart(64, "0");
|
|
486
|
+
if (signature === undefined || signature === null || signature === "") {
|
|
487
|
+
return { head: `${word}${ZERO_WORD}01` };
|
|
488
|
+
}
|
|
489
|
+
if (!isHexBytes(signature) || signature.length < 132)
|
|
490
|
+
return null;
|
|
491
|
+
const bytes = signature.slice(2).toLowerCase();
|
|
492
|
+
const v = bytes.slice(128, 130);
|
|
493
|
+
if (v !== "00") {
|
|
494
|
+
if (bytes.length !== 130 || (v === "01" && bytes.slice(0, 64) !== word)) {
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
return { head: bytes };
|
|
498
|
+
}
|
|
499
|
+
if (bytes.length < 194 ||
|
|
500
|
+
bytes.slice(0, 64) !== word ||
|
|
501
|
+
BigInt(`0x${bytes.slice(64, 128)}`) !== 65n) {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
const end = 194n + 2n * BigInt(`0x${bytes.slice(130, 194)}`);
|
|
505
|
+
if (end > BigInt(bytes.length) ||
|
|
506
|
+
BigInt(bytes.length) - end >= 64n ||
|
|
507
|
+
/[^0]/.test(bytes.slice(Number(end)))) {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
return { contract: bytes.slice(194, Number(end)) };
|
|
511
|
+
}
|
|
512
|
+
function requireOwners(owners) {
|
|
513
|
+
if (!Array.isArray(owners)) {
|
|
514
|
+
throw new Error(`Invalid Safe owners: ${String(owners)}.`);
|
|
515
|
+
}
|
|
516
|
+
return new Set(owners.map((owner) => {
|
|
517
|
+
if (typeof owner !== "string" || !isAddress(owner)) {
|
|
518
|
+
throw new Error(`Invalid Safe owner: ${String(owner)}.`);
|
|
519
|
+
}
|
|
520
|
+
return owner.toLowerCase();
|
|
521
|
+
}));
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* The confirmations of `tx` that count: one per current owner in `owners`,
|
|
525
|
+
* with a well-formed signature or none (an onchain approval), a signature
|
|
526
|
+
* preferred, in ascending numeric owner order as Safe requires (a string sort
|
|
527
|
+
* disagrees and reverts with GS026). Owners are checksummed.
|
|
528
|
+
*/
|
|
529
|
+
export function usableSafeConfirmations(tx, owners) {
|
|
530
|
+
const allowed = requireOwners(owners);
|
|
531
|
+
const byOwner = new Map();
|
|
532
|
+
for (const confirmation of Array.isArray(tx?.confirmations)
|
|
533
|
+
? tx.confirmations
|
|
534
|
+
: []) {
|
|
535
|
+
const owner = confirmation?.owner;
|
|
536
|
+
if (typeof owner !== "string" ||
|
|
537
|
+
!isAddress(owner) ||
|
|
538
|
+
!allowed.has(owner.toLowerCase()) ||
|
|
539
|
+
signaturePart(owner, confirmation.signature) === null) {
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
const key = owner.toLowerCase();
|
|
543
|
+
const existing = byOwner.get(key);
|
|
544
|
+
if (!existing || (!existing.signature && confirmation.signature)) {
|
|
545
|
+
byOwner.set(key, {
|
|
546
|
+
owner: getAddress(owner),
|
|
547
|
+
signature: confirmation.signature ?? null,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
// Owners are unique here, so no two compare equal.
|
|
552
|
+
return [...byOwner.values()].sort((left, right) => BigInt(left.owner) < BigInt(right.owner) ? -1 : 1);
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* The `signatures` bytes for `execTransaction`, as Safe's checkNSignatures
|
|
556
|
+
* reads them: one 65-byte head per {@link usableSafeConfirmations} entry, in
|
|
557
|
+
* owner order, then each contract signature's length and bytes. A contract
|
|
558
|
+
* signature's head points at its tail by byte offset, which is never below
|
|
559
|
+
* the heads' total length, so it passes Safe's GS021 check.
|
|
560
|
+
*/
|
|
561
|
+
export function safeExecutionSignatures(tx, owners) {
|
|
562
|
+
const parts = usableSafeConfirmations(tx, owners).map(({ owner, signature }) => ({
|
|
563
|
+
owner,
|
|
564
|
+
part: signaturePart(owner, signature),
|
|
565
|
+
}));
|
|
566
|
+
let head = "";
|
|
567
|
+
let tail = "";
|
|
568
|
+
for (const { owner, part } of parts) {
|
|
569
|
+
if ("head" in part) {
|
|
570
|
+
head += part.head;
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
const offset = parts.length * 65 + tail.length / 2;
|
|
574
|
+
head += `${owner.slice(2).toLowerCase().padStart(64, "0")}${offset.toString(16).padStart(64, "0")}00`;
|
|
575
|
+
tail += `${(part.contract.length / 2).toString(16).padStart(64, "0")}${part.contract}`;
|
|
576
|
+
}
|
|
577
|
+
return `0x${head}${tail}`;
|
|
578
|
+
}
|
|
579
|
+
/** `execTransaction`'s arguments for `tx`, signed by its usable confirmations from `owners`. */
|
|
580
|
+
export function safeExecutionArgs(tx, owners) {
|
|
581
|
+
const message = safeTransactionMessage(tx);
|
|
582
|
+
return [
|
|
583
|
+
message.to,
|
|
584
|
+
message.value,
|
|
585
|
+
message.data,
|
|
586
|
+
message.operation,
|
|
587
|
+
message.safeTxGas,
|
|
588
|
+
message.baseGas,
|
|
589
|
+
message.gasPrice,
|
|
590
|
+
message.gasToken,
|
|
591
|
+
message.refundReceiver,
|
|
592
|
+
safeExecutionSignatures(tx, owners),
|
|
593
|
+
];
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Whether executing `tx` pays its executor a refund out of the Safe: Safe pays
|
|
597
|
+
* one only when gasPrice is above zero (in the gas token, or ETH, to the refund
|
|
598
|
+
* receiver or the executor). This SDK never proposes one. A queue card shows
|
|
599
|
+
* or refuses such a transaction before executing it. Throws on a malformed
|
|
600
|
+
* record, as {@link safeTransactionMessage} does.
|
|
601
|
+
*/
|
|
602
|
+
export function safeTransactionHasRefund(tx) {
|
|
603
|
+
return safeTransactionMessage(tx).gasPrice > 0n;
|
|
604
|
+
}
|
|
605
|
+
// ── Proposals ────────────────────────────────────────────────────────────────
|
|
606
|
+
/** The zero-refund Safe transaction this SDK proposes: one CALL, no gas fields. */
|
|
607
|
+
export function safeProposalFor(call, nonce) {
|
|
608
|
+
const tx = {
|
|
609
|
+
to: fieldAddress("to", call?.to),
|
|
610
|
+
value: fieldUint("value", call?.value ?? 0n).toString(),
|
|
611
|
+
data: call?.data,
|
|
612
|
+
operation: 0,
|
|
613
|
+
safeTxGas: "0",
|
|
614
|
+
baseGas: "0",
|
|
615
|
+
gasPrice: "0",
|
|
616
|
+
gasToken: zeroAddress,
|
|
617
|
+
refundReceiver: zeroAddress,
|
|
618
|
+
nonce: nonceOf(nonce),
|
|
619
|
+
confirmations: [],
|
|
620
|
+
};
|
|
621
|
+
safeTransactionMessage(tx);
|
|
622
|
+
return tx;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* One Safe transaction for a batch: a DELEGATECALL into MultiSendCallOnly,
|
|
626
|
+
* which runs each call from the Safe, in order, or reverts them all.
|
|
627
|
+
*/
|
|
628
|
+
export function safeBatchProposalFor(calls, nonce) {
|
|
629
|
+
return {
|
|
630
|
+
...safeProposalFor({ to: MULTI_SEND_CALL_ONLY, data: encodeMultiSend(calls) }, nonce),
|
|
631
|
+
operation: 1,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Whether `tx` is exactly `call` as a zero-refund proposal: the same target,
|
|
636
|
+
* value, data and operation, and no safeTxGas, baseGas, gasPrice, gas token or
|
|
637
|
+
* refund receiver. A malformed `tx` never matches.
|
|
638
|
+
*/
|
|
639
|
+
export function safeTransactionMatchesCall(tx, call) {
|
|
640
|
+
let message;
|
|
641
|
+
try {
|
|
642
|
+
message = safeTransactionMessage(tx);
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
return (typeof call?.to === "string" &&
|
|
648
|
+
isAddress(call.to) &&
|
|
649
|
+
isAddressEqual(message.to, call.to) &&
|
|
650
|
+
message.value === (call.value ?? 0n) &&
|
|
651
|
+
message.data.toLowerCase() === String(call.data).toLowerCase() &&
|
|
652
|
+
message.operation === (call.operation ?? 0) &&
|
|
653
|
+
message.safeTxGas === 0n &&
|
|
654
|
+
message.baseGas === 0n &&
|
|
655
|
+
message.gasPrice === 0n &&
|
|
656
|
+
isAddressEqual(message.gasToken, zeroAddress) &&
|
|
657
|
+
isAddressEqual(message.refundReceiver, zeroAddress));
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* The nonce a new proposal takes: after everything already queued, never below
|
|
661
|
+
* the Safe's own. Reusing a queued nonce would offer a replacement instead.
|
|
662
|
+
*/
|
|
663
|
+
export function nextProposalNonce(currentNonce, pending) {
|
|
664
|
+
return pending.reduce((next, tx) => Math.max(next, nonceOf(tx?.nonce) + 1), nonceOf(currentNonce));
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* The next onchain step for `account` on a chain with no Safe service. Safe
|
|
668
|
+
* counts the executing owner as a signature, so the owner who would complete
|
|
669
|
+
* the threshold executes instead of approving first.
|
|
670
|
+
*/
|
|
671
|
+
export function onchainApprovalStep({ account, approved, threshold, }) {
|
|
672
|
+
const others = approved.filter((owner) => !isAddressEqual(owner, account));
|
|
673
|
+
if (others.length + 1 >= threshold) {
|
|
674
|
+
return { kind: "execute", signers: [...others, account] };
|
|
675
|
+
}
|
|
676
|
+
return others.length === approved.length
|
|
677
|
+
? { kind: "approve" }
|
|
678
|
+
: { kind: "waiting" };
|
|
679
|
+
}
|
|
680
|
+
const PENDING_PAGE_SIZE = 50;
|
|
681
|
+
const MAX_PENDING = 250;
|
|
682
|
+
const SERVICE_DETAIL_CHARACTERS = 200;
|
|
683
|
+
/** Whether Safe hosts a transaction service for `chainId`; without one, owners approve onchain. */
|
|
684
|
+
export function hasSafeService(chainId) {
|
|
685
|
+
return safeServiceBase(chainId) !== null;
|
|
686
|
+
}
|
|
687
|
+
/** The Safe app's page for one queued transaction. */
|
|
688
|
+
export function safeTransactionUrl(chainId, safe, safeTxHash) {
|
|
689
|
+
const prefix = SAFE_PREFIX[chainId];
|
|
690
|
+
if (!prefix || typeof safe !== "string" || !isAddress(safe))
|
|
691
|
+
return null;
|
|
692
|
+
if (!isBytes32(safeTxHash))
|
|
693
|
+
return null;
|
|
694
|
+
const address = getAddress(safe);
|
|
695
|
+
return `https://app.safe.global/transactions/tx?safe=${prefix}:${address}&id=multisig_${address}_${safeTxHash}`;
|
|
696
|
+
}
|
|
697
|
+
function serviceOrThrow(chainId) {
|
|
698
|
+
const base = safeServiceBase(chainId);
|
|
699
|
+
if (!base) {
|
|
700
|
+
throw new Error(`Safe does not host a transaction service on chain ${chainId}.`);
|
|
701
|
+
}
|
|
702
|
+
return base;
|
|
703
|
+
}
|
|
704
|
+
/** The optional `jb-safe-api-key` from local storage, as a bearer token. */
|
|
705
|
+
function serviceHeaders(json = false) {
|
|
706
|
+
const headers = { accept: "application/json" };
|
|
707
|
+
if (json)
|
|
708
|
+
headers["content-type"] = "application/json";
|
|
709
|
+
try {
|
|
710
|
+
const key = globalThis.localStorage?.getItem("jb-safe-api-key");
|
|
711
|
+
if (key)
|
|
712
|
+
headers.authorization = `Bearer ${key}`;
|
|
713
|
+
}
|
|
714
|
+
catch {
|
|
715
|
+
// The key is optional.
|
|
716
|
+
}
|
|
717
|
+
return headers;
|
|
718
|
+
}
|
|
719
|
+
/** One service request. A 429 is refused before processing, so it waits and repeats, up to three times. */
|
|
720
|
+
async function serviceFetch(url, init, options) {
|
|
721
|
+
const request = options.fetch ?? fetch;
|
|
722
|
+
for (let attempt = 0;; attempt += 1) {
|
|
723
|
+
const response = await request(url, init);
|
|
724
|
+
if (response.status !== 429 || attempt >= 3)
|
|
725
|
+
return response;
|
|
726
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
727
|
+
await new Promise((resolve) => setTimeout(resolve, Number.isFinite(retryAfter) && retryAfter > 0
|
|
728
|
+
? retryAfter * 1000
|
|
729
|
+
: 1000 * (attempt + 1)));
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
async function serviceDetail(response) {
|
|
733
|
+
const text = await response.text().catch(() => "");
|
|
734
|
+
return text ? `: ${text.slice(0, SERVICE_DETAIL_CHARACTERS)}` : "";
|
|
735
|
+
}
|
|
736
|
+
/** The most confirmations a service row may carry; each must be an owner's. */
|
|
737
|
+
const MAX_ROW_CONFIRMATIONS = 100;
|
|
738
|
+
/**
|
|
739
|
+
* Every field of a service row read strictly, its advertised hash required and
|
|
740
|
+
* checked, its confirmations bounded: the row with its nonce as a number and
|
|
741
|
+
* its own Safe and hash, or a refusal naming what is wrong.
|
|
742
|
+
*/
|
|
743
|
+
function checkedRow(chainId, safe, row) {
|
|
744
|
+
try {
|
|
745
|
+
const tx = row;
|
|
746
|
+
if ((tx?.safeTxHash === undefined || tx.safeTxHash === null) &&
|
|
747
|
+
(tx?.contractTransactionHash === undefined ||
|
|
748
|
+
tx.contractTransactionHash === null)) {
|
|
749
|
+
throw new Error("It advertises no safeTxHash.");
|
|
750
|
+
}
|
|
751
|
+
if (tx.confirmations !== undefined &&
|
|
752
|
+
tx.confirmations !== null &&
|
|
753
|
+
(!Array.isArray(tx.confirmations) ||
|
|
754
|
+
tx.confirmations.length > MAX_ROW_CONFIRMATIONS)) {
|
|
755
|
+
throw new Error(`Its confirmations are not a list of at most ${MAX_ROW_CONFIRMATIONS}.`);
|
|
756
|
+
}
|
|
757
|
+
const hash = canonicalSafeTxHash(chainId, safe, tx);
|
|
758
|
+
return { ...tx, safe, safeTxHash: hash, nonce: nonceOf(tx.nonce) };
|
|
759
|
+
}
|
|
760
|
+
catch (error) {
|
|
761
|
+
throw new Error(`Safe's transaction service listed a transaction for Safe ${safe} on chain ${chainId} that cannot be trusted. ${error.message}`);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Every queued, unexecuted transaction of `safe` from nonce `currentNonce`
|
|
766
|
+
* on, read from the chain's transaction service. Each row must belong to
|
|
767
|
+
* `safe` and hash to its advertised safeTxHash. Throws when the chain has no
|
|
768
|
+
* service or the service fails, so an outage never reads as an empty queue,
|
|
769
|
+
* and when more than 250 are queued.
|
|
770
|
+
*/
|
|
771
|
+
export async function listPendingSafeTransactions(chainId, safe, currentNonce, options = {}) {
|
|
772
|
+
const base = serviceOrThrow(chainId);
|
|
773
|
+
const address = requireSafe(safe);
|
|
774
|
+
const fromNonce = nonceOf(currentNonce);
|
|
775
|
+
const rows = [];
|
|
776
|
+
for (let offset = 0; offset < MAX_PENDING; offset += PENDING_PAGE_SIZE) {
|
|
777
|
+
const url = `${base}/api/v1/safes/${address}/multisig-transactions/` +
|
|
778
|
+
`?executed=false&trusted=true&ordering=nonce&limit=${PENDING_PAGE_SIZE}` +
|
|
779
|
+
`&offset=${offset}&nonce__gte=${fromNonce}`;
|
|
780
|
+
let response = await serviceFetch(url, { headers: serviceHeaders() }, options);
|
|
781
|
+
if (!response.ok) {
|
|
782
|
+
// A failed page is retried once before the listing gives up.
|
|
783
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
784
|
+
response = await serviceFetch(url, { headers: serviceHeaders() }, options);
|
|
785
|
+
}
|
|
786
|
+
if (!response.ok) {
|
|
787
|
+
throw new Error(`Safe's transaction service answered ${response.status} listing the queue of Safe ${address} on chain ${chainId}${await serviceDetail(response)}`);
|
|
788
|
+
}
|
|
789
|
+
const body = (await response.json());
|
|
790
|
+
if (!body || !Array.isArray(body.results)) {
|
|
791
|
+
throw new Error(`Safe's transaction service returned no transaction list for Safe ${address} on chain ${chainId}.`);
|
|
792
|
+
}
|
|
793
|
+
if (body.results.length > PENDING_PAGE_SIZE) {
|
|
794
|
+
throw new Error(`Safe's transaction service returned ${body.results.length} transactions in a page of ${PENDING_PAGE_SIZE} for Safe ${address} on chain ${chainId}.`);
|
|
795
|
+
}
|
|
796
|
+
rows.push(...body.results.map((row) => checkedRow(chainId, address, row)));
|
|
797
|
+
if (body.results.length < PENDING_PAGE_SIZE || !body.next) {
|
|
798
|
+
return rows.filter((row) => row.isExecuted !== true && row.nonce >= fromNonce);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
throw new Error(`Safe ${address} has more than ${MAX_PENDING} queued transactions on chain ${chainId}. Nothing was proposed; execute or replace older ones first.`);
|
|
802
|
+
}
|
|
803
|
+
/** The queued zero-refund proposal of exactly `call`, so a retry can confirm it instead of proposing it twice. */
|
|
804
|
+
export async function findPendingSafeTransaction(chainId, safe, currentNonce, call, options = {}) {
|
|
805
|
+
const pending = await listPendingSafeTransactions(chainId, safe, currentNonce, options);
|
|
806
|
+
return pending.find((tx) => safeTransactionMatchesCall(tx, call)) ?? null;
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* The service's record of the proposal `safeTxHash`, authenticated: it must
|
|
810
|
+
* belong to `safe` and its fields must hash to `safeTxHash`.
|
|
811
|
+
*/
|
|
812
|
+
export async function readSafeTransaction(chainId, safe, safeTxHash, options = {}) {
|
|
813
|
+
const base = serviceOrThrow(chainId);
|
|
814
|
+
const address = requireSafe(safe);
|
|
815
|
+
if (!isBytes32(safeTxHash)) {
|
|
816
|
+
throw new Error(`Invalid Safe transaction hash: ${String(safeTxHash)}.`);
|
|
817
|
+
}
|
|
818
|
+
const response = await serviceFetch(`${base}/api/v1/multisig-transactions/${safeTxHash}/`, { headers: serviceHeaders() }, options);
|
|
819
|
+
if (!response.ok) {
|
|
820
|
+
throw new Error(`Safe's transaction service answered ${response.status} for proposal ${safeTxHash} on chain ${chainId}. Try again when it is available.`);
|
|
821
|
+
}
|
|
822
|
+
const record = (await response.json());
|
|
823
|
+
try {
|
|
824
|
+
if (record?.safe === undefined || record.safe === null) {
|
|
825
|
+
throw new Error("The record names no Safe.");
|
|
826
|
+
}
|
|
827
|
+
canonicalSafeTxHash(chainId, address, record, safeTxHash);
|
|
828
|
+
}
|
|
829
|
+
catch (error) {
|
|
830
|
+
throw new Error(`Safe's record of proposal ${safeTxHash} does not match Safe ${address} on chain ${chainId}. ${error.message}`);
|
|
831
|
+
}
|
|
832
|
+
return record;
|
|
833
|
+
}
|
|
834
|
+
async function postToService(url, body, what, options) {
|
|
835
|
+
const response = await serviceFetch(url, {
|
|
836
|
+
method: "POST",
|
|
837
|
+
headers: serviceHeaders(true),
|
|
838
|
+
body: JSON.stringify(body),
|
|
839
|
+
}, options);
|
|
840
|
+
if (!response.ok && response.status !== 201) {
|
|
841
|
+
throw new Error(`Safe's transaction service refused ${what} (${response.status})${await serviceDetail(response)}`);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
function requireSignature(signature) {
|
|
845
|
+
if (!isHexBytes(signature) || signature.length < 132) {
|
|
846
|
+
throw new Error(`Invalid Safe signature: ${String(signature)}.`);
|
|
847
|
+
}
|
|
848
|
+
return signature;
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Queue `tx`, signed by `sender`, with the transaction service so the Safe's
|
|
852
|
+
* other owners can confirm and execute it. The body is built from the exact
|
|
853
|
+
* fields, checksummed as the service requires, and the hash is recomputed,
|
|
854
|
+
* never taken from the record. Returns the safeTxHash.
|
|
855
|
+
*/
|
|
856
|
+
export async function proposeSafeTransaction(chainId, safe, tx, { sender, signature, origin, }, options = {}) {
|
|
857
|
+
const base = serviceOrThrow(chainId);
|
|
858
|
+
const address = requireSafe(safe);
|
|
859
|
+
const safeTxHash = canonicalSafeTxHash(chainId, address, tx);
|
|
860
|
+
const message = safeTransactionMessage(tx);
|
|
861
|
+
if (typeof origin !== "string" || !origin.trim()) {
|
|
862
|
+
throw new Error("A Safe proposal needs an origin naming the app.");
|
|
863
|
+
}
|
|
864
|
+
await postToService(`${base}/api/v1/safes/${address}/multisig-transactions/`, {
|
|
865
|
+
to: message.to,
|
|
866
|
+
value: message.value.toString(),
|
|
867
|
+
data: message.data,
|
|
868
|
+
operation: message.operation,
|
|
869
|
+
safeTxGas: message.safeTxGas.toString(),
|
|
870
|
+
baseGas: message.baseGas.toString(),
|
|
871
|
+
gasPrice: message.gasPrice.toString(),
|
|
872
|
+
gasToken: message.gasToken,
|
|
873
|
+
refundReceiver: message.refundReceiver,
|
|
874
|
+
nonce: message.nonce.toString(),
|
|
875
|
+
contractTransactionHash: safeTxHash,
|
|
876
|
+
sender: fieldAddress("sender", sender),
|
|
877
|
+
signature: requireSignature(signature),
|
|
878
|
+
origin,
|
|
879
|
+
}, `the proposal ${safeTxHash} for Safe ${address} on chain ${chainId}`, options);
|
|
880
|
+
return safeTxHash;
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Add an owner's signature to the queued `tx`. The confirmation is posted to
|
|
884
|
+
* the hash of `tx`'s exact fields, never to a hash the record advertises.
|
|
885
|
+
*/
|
|
886
|
+
export async function submitSafeConfirmation(chainId, safe, tx, signature, options = {}) {
|
|
887
|
+
const base = serviceOrThrow(chainId);
|
|
888
|
+
const safeTxHash = canonicalSafeTxHash(chainId, safe, tx);
|
|
889
|
+
await postToService(`${base}/api/v1/multisig-transactions/${safeTxHash}/confirmations/`, { signature: requireSignature(signature) }, `the confirmation of ${safeTxHash} on chain ${chainId}`, options);
|
|
890
|
+
}
|
|
891
|
+
/** The most Safes listed for one owner on one chain; the rest are not read. */
|
|
892
|
+
const MAX_OWNED_SAFES = 200;
|
|
893
|
+
/**
|
|
894
|
+
* Every Safe `owner` signs for on `chainIds`, from each chain's transaction
|
|
895
|
+
* service, at most 200 per chain. A chain without a service, a failed request
|
|
896
|
+
* or a malformed answer contributes nothing; listed Safes are checksummed and
|
|
897
|
+
* others dropped.
|
|
898
|
+
*/
|
|
899
|
+
export async function fetchSafesOwnedBy(owner, chainIds, options = {}) {
|
|
900
|
+
if (typeof owner !== "string" || !isAddress(owner))
|
|
901
|
+
return [];
|
|
902
|
+
const account = getAddress(owner);
|
|
903
|
+
const perChain = await Promise.all(chainIds.map(async (chainId) => {
|
|
904
|
+
const base = safeServiceBase(chainId);
|
|
905
|
+
if (!base)
|
|
906
|
+
return [];
|
|
907
|
+
try {
|
|
908
|
+
const response = await serviceFetch(`${base}/api/v1/owners/${account}/safes/`, { headers: serviceHeaders() }, options);
|
|
909
|
+
if (!response.ok)
|
|
910
|
+
return [];
|
|
911
|
+
const body = (await response.json());
|
|
912
|
+
return Array.isArray(body?.safes)
|
|
913
|
+
? body.safes
|
|
914
|
+
.slice(0, MAX_OWNED_SAFES)
|
|
915
|
+
.flatMap((safe) => typeof safe === "string" && isAddress(safe)
|
|
916
|
+
? [{ chainId, safe: getAddress(safe) }]
|
|
917
|
+
: [])
|
|
918
|
+
: [];
|
|
919
|
+
}
|
|
920
|
+
catch {
|
|
921
|
+
return [];
|
|
922
|
+
}
|
|
923
|
+
}));
|
|
924
|
+
return perChain.flat();
|
|
925
|
+
}
|
|
926
|
+
/** The source chain's service URL for a Safe's creation record, or null. */
|
|
927
|
+
export function safeCreationUrl(chainId, safe) {
|
|
928
|
+
const base = safeServiceBase(chainId);
|
|
929
|
+
if (!base || typeof safe !== "string" || !isAddress(safe))
|
|
930
|
+
return null;
|
|
931
|
+
return `${base}/api/v1/safes/${getAddress(safe)}/creation/`;
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* A service creation record read strictly: the factory and singleton as
|
|
935
|
+
* addresses of one recognized Safe release, whole-hex setup data with at
|
|
936
|
+
* least a selector, and a decimal uint256 salt. Null for anything else.
|
|
937
|
+
*/
|
|
938
|
+
export function parseSafeCreationPayload(payload) {
|
|
939
|
+
if (!payload || typeof payload !== "object")
|
|
940
|
+
return null;
|
|
941
|
+
const record = payload;
|
|
942
|
+
const { factoryAddress, masterCopy, setupData, saltNonce } = record;
|
|
943
|
+
if (typeof factoryAddress !== "string" ||
|
|
944
|
+
typeof masterCopy !== "string" ||
|
|
945
|
+
!isAddress(factoryAddress) ||
|
|
946
|
+
!isAddress(masterCopy) ||
|
|
947
|
+
!isHexBytes(setupData) ||
|
|
948
|
+
setupData.length < 10 ||
|
|
949
|
+
typeof saltNonce !== "string" ||
|
|
950
|
+
!/^\d+$/.test(saltNonce) ||
|
|
951
|
+
uint256(saltNonce) === null ||
|
|
952
|
+
!isRecognizedSafeDeployment(factoryAddress, masterCopy)) {
|
|
953
|
+
return null;
|
|
954
|
+
}
|
|
955
|
+
return {
|
|
956
|
+
factory: getAddress(factoryAddress),
|
|
957
|
+
singleton: getAddress(masterCopy),
|
|
958
|
+
initializer: setupData,
|
|
959
|
+
saltNonce: BigInt(saltNonce),
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* The Safe's creation record from its source chain's service only: a record
|
|
964
|
+
* for the same address from another chain could replay unrelated setup data.
|
|
965
|
+
* Null when the chain has no service, the request fails, or the record is
|
|
966
|
+
* not {@link parseSafeCreationPayload strictly valid}.
|
|
967
|
+
*/
|
|
968
|
+
export async function fetchSafeCreation(safe, sourceChainId, options = {}) {
|
|
969
|
+
const url = safeCreationUrl(sourceChainId, safe);
|
|
970
|
+
if (!url)
|
|
971
|
+
return null;
|
|
972
|
+
try {
|
|
973
|
+
const response = await serviceFetch(url, { headers: serviceHeaders() }, options);
|
|
974
|
+
return response.ok ? parseSafeCreationPayload(await response.json()) : null;
|
|
975
|
+
}
|
|
976
|
+
catch {
|
|
977
|
+
return null;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
167
980
|
//# sourceMappingURL=safeService.js.map
|