-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcreate.ts
More file actions
448 lines (391 loc) · 14.3 KB
/
create.ts
File metadata and controls
448 lines (391 loc) · 14.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
import * as p from '@clack/prompts'
import { isAddress, type Address } from 'viem'
import { getConfigStore } from '../../storage/config-store.js'
import { getSafeStorage } from '../../storage/safe-store.js'
import { getTransactionStore } from '../../storage/transaction-store.js'
import { getWalletStorage } from '../../storage/wallet-store.js'
import { TransactionService } from '../../services/transaction-service.js'
import { ContractService } from '../../services/contract-service.js'
import { ABIService } from '../../services/abi-service.js'
import { TransactionBuilder } from '../../services/transaction-builder.js'
import { SafeCLIError } from '../../utils/errors.js'
import { formatSafeAddress } from '../../utils/eip3770.js'
import { validateAndChecksumAddress } from '../../utils/validation.js'
import { renderScreen } from '../../ui/render.js'
import { TransactionCreateSuccessScreen } from '../../ui/screens/index.js'
export async function createTransaction() {
p.intro('Create Safe Transaction')
try {
const safeStorage = getSafeStorage()
const configStore = getConfigStore()
const walletStorage = getWalletStorage()
const transactionStore = getTransactionStore()
const activeWallet = walletStorage.getActiveWallet()
if (!activeWallet) {
p.log.error('No active wallet set. Please import a wallet first.')
p.outro('Setup required')
return
}
// Get all Safes
const safes = safeStorage.getAllSafes()
if (safes.length === 0) {
p.log.error('No Safes found. Please create a Safe first.')
p.outro('Setup required')
return
}
const chains = configStore.getAllChains()
// Select Safe
const safeKey = (await p.select({
message: 'Select Safe to create transaction for',
options: safes.map((safe) => {
const eip3770 = formatSafeAddress(safe.address as Address, safe.chainId, chains)
const chain = configStore.getChain(safe.chainId)
return {
value: `${safe.chainId}:${safe.address}`,
label: `${safe.name} (${eip3770})`,
hint: chain?.name || safe.chainId,
}
}),
})) as string
if (p.isCancel(safeKey)) {
p.cancel('Operation cancelled')
return
}
const [chainId, address] = safeKey.split(':')
const safe = safeStorage.getSafe(chainId, address as Address)
if (!safe) {
p.log.error('Safe not found')
p.outro('Failed')
return
}
// Get chain
const chain = configStore.getChain(chainId)
if (!chain) {
p.log.error(`Chain ${chainId} not found in configuration`)
p.outro('Failed')
return
}
// Fetch live owners from blockchain
const spinner = p.spinner()
spinner.start('Fetching Safe information from blockchain...')
let owners: Address[]
try {
const txService = new TransactionService(chain)
owners = await txService.getOwners(address as Address)
spinner.stop('Safe information fetched')
} catch (error) {
spinner.stop('Failed to fetch Safe information')
p.log.error(
error instanceof Error ? error.message : 'Failed to fetch Safe data from blockchain'
)
p.outro('Failed')
return
}
// Check if wallet is an owner
if (!owners.some((owner) => owner.toLowerCase() === activeWallet.address.toLowerCase())) {
p.log.error('Active wallet is not an owner of this Safe')
p.outro('Failed')
return
}
// Get transaction details
const toInput = await p.text({
message: 'To address',
placeholder: '0x...',
validate: (value) => {
if (!value) return 'Address is required'
if (!isAddress(value)) return 'Invalid Ethereum address'
return undefined
},
})
if (p.isCancel(toInput)) {
p.cancel('Operation cancelled')
return
}
// Checksum the address immediately
let to: Address
try {
to = validateAndChecksumAddress(toInput as string)
} catch (error) {
p.log.error(error instanceof Error ? error.message : 'Invalid address')
p.outro('Failed')
return
}
// Check if address is a contract
const contractService = new ContractService(chain)
let isContract = false
let value = '0'
let data: `0x${string}` = '0x'
const spinner2 = p.spinner()
spinner2.start('Checking if address is a contract...')
try {
isContract = await contractService.isContract(to)
spinner2.stop(isContract ? 'Contract detected' : 'EOA (regular address)')
} catch (error) {
spinner2.stop('Failed to check contract')
p.log.warning('Could not determine if address is a contract, falling back to manual input')
}
// If contract, try to fetch ABI and use transaction builder
if (isContract) {
console.log('')
console.log()
const config = configStore.getConfig()
const etherscanApiKey = config.preferences?.etherscanApiKey
// Inform user about ABI source based on API key availability
if (!etherscanApiKey) {
console.log(' Using Sourcify for ABI (free, no API key required)')
console.log(' Note: Proxy contract detection requires an Etherscan API key')
}
const abiService = new ABIService(chain, etherscanApiKey)
let abi: any = null
let contractName: string | undefined
try {
const contractInfo = await abiService.fetchContractInfo(to)
abi = contractInfo.abi
contractName = contractInfo.name
const implementationAddress = contractInfo.implementation
// Check if Etherscan detected this as a proxy
if (implementationAddress) {
console.log(`✓ Proxy detected! Implementation: ${implementationAddress}`)
if (contractName) {
console.log(`✓ Proxy ABI found: ${contractName}`)
} else {
console.log('✓ Proxy ABI found!')
}
} else {
if (contractName) {
console.log(`✓ Contract ABI found: ${contractName}`)
} else {
console.log('✓ Contract ABI found!')
}
}
// If proxy, also fetch implementation ABI and merge
if (implementationAddress) {
try {
const implInfo = await abiService.fetchContractInfo(implementationAddress)
const implAbi = implInfo.abi
// Use implementation name as the main contract name
if (implInfo.name) {
contractName = implInfo.name
console.log(`✓ Implementation ABI found: ${implInfo.name}`)
} else {
console.log('✓ Implementation ABI found!')
}
// Merge ABIs (implementation functions + proxy functions)
// Filter out duplicates by function signature
const combinedAbi = [...implAbi]
const existingSignatures = new Set(
implAbi
.filter((item: any) => item.type === 'function')
.map(
(item: any) =>
`${item.name}(${item.inputs?.map((i: any) => i.type).join(',') || ''})`
)
)
for (const item of abi) {
if (item.type === 'function') {
const sig = `${item.name}(${item.inputs?.map((i: any) => i.type).join(',') || ''})`
if (!existingSignatures.has(sig)) {
combinedAbi.push(item)
}
} else {
// Include events, errors, etc.
combinedAbi.push(item)
}
}
abi = combinedAbi
console.log(` Combined: ${abi.length} items total`)
} catch (error) {
console.log('⚠ Could not fetch implementation ABI, using proxy ABI only')
console.log(` Found ${abi.length} items in proxy ABI`)
}
} else {
console.log(` Found ${abi.length} items in ABI`)
}
} catch (error) {
console.log('⚠ Could not fetch ABI')
console.log(' Contract may not be verified. Falling back to manual input.')
}
// If ABI found, offer transaction builder
if (abi) {
const functions = abiService.extractFunctions(abi)
console.log('')
if (functions.length > 0) {
console.log(`✓ Found ${functions.length} writable function(s)`)
const useBuilder = await p.confirm({
message: 'Use transaction builder to interact with contract?',
initialValue: true,
})
if (p.isCancel(useBuilder)) {
p.cancel('Operation cancelled')
return
}
if (useBuilder) {
// Show function selector with pagination
// Use function signature as unique identifier to handle overloaded functions
const selectedFuncSig = await p.select({
message: 'Select function to call:',
options: functions.map((func) => {
const signature = `${func.name}(${func.inputs?.map((i: any) => i.type).join(',') || ''})`
return {
value: signature,
label: abiService.formatFunctionSignature(func),
hint: func.stateMutability === 'payable' ? 'payable' : undefined,
}
}),
maxItems: 15, // Limit visible items for pagination
})
if (p.isCancel(selectedFuncSig)) {
p.cancel('Operation cancelled')
return
}
const func = functions.find((f) => {
const sig = `${f.name}(${f.inputs?.map((i: any) => i.type).join(',') || ''})`
return sig === selectedFuncSig
})
if (!func) {
p.log.error('Function not found')
p.outro('Failed')
return
}
// Build transaction using interactive builder
const builder = new TransactionBuilder(abi)
const result = await builder.buildFunctionCall(func)
value = result.value
data = result.data
}
} else {
console.log('⚠ No writable functions found in ABI')
console.log(' Contract may only have view/pure functions')
console.log(' Falling back to manual input')
}
}
}
// Manual input if not using transaction builder
if (data === '0x') {
value = (await p.text({
message: 'Value in wei (0 for token transfer)',
placeholder: '0',
initialValue: '0',
validate: (val) => {
if (!val) return 'Value is required'
try {
BigInt(val)
return undefined
} catch {
return 'Invalid number'
}
},
})) as string
if (p.isCancel(value)) {
p.cancel('Operation cancelled')
return
}
data = (await p.text({
message: 'Transaction data (hex)',
placeholder: '0x',
initialValue: '0x',
validate: (val) => {
if (!val) return 'Data is required (use 0x for empty)'
if (!val.startsWith('0x')) return 'Data must start with 0x'
if (val.length > 2 && !/^0x[0-9a-fA-F]*$/.test(val)) {
return 'Data must be valid hex'
}
return undefined
},
})) as `0x${string}`
if (p.isCancel(data)) {
p.cancel('Operation cancelled')
return
}
}
const operation = (await p.select({
message: 'Operation type',
options: [
{ value: 0, label: 'Call', hint: 'Standard transaction call' },
{ value: 1, label: 'DelegateCall', hint: 'Delegate call (advanced)' },
],
initialValue: 0,
})) as number as 0 | 1
if (p.isCancel(operation)) {
p.cancel('Operation cancelled')
return
}
// Get current Safe nonce for recommendation
const txService = new TransactionService(chain)
let currentNonce: number
try {
currentNonce = await txService.getNonce(safe.address as Address)
} catch (error) {
p.log.error(
`Failed to get Safe nonce: ${error instanceof Error ? error.message : 'Unknown error'}`
)
p.outro('Failed')
return
}
// Ask for nonce (optional, with recommended value)
const nonceInput = (await p.text({
message: 'Transaction nonce (leave empty for default)',
placeholder: `${currentNonce} (recommended: current nonce)`,
validate: (value) => {
if (!value) return undefined // Empty is OK (will use default)
const num = parseInt(value, 10)
if (isNaN(num) || num < 0) return 'Nonce must be a non-negative number'
if (num < currentNonce)
return `Nonce cannot be lower than current Safe nonce (${currentNonce})`
return undefined
},
})) as string
if (p.isCancel(nonceInput)) {
p.cancel('Operation cancelled')
return
}
const nonce = nonceInput ? parseInt(nonceInput, 10) : undefined
// Create transaction
const createSpinner = p.spinner()
createSpinner.start('Creating transaction')
const createdTx = await txService.createTransaction(safe.address as Address, {
to,
value,
data,
operation,
nonce,
})
// Store transaction with safeTxHash as ID
transactionStore.createTransaction(
createdTx.safeTxHash,
safe.address as Address,
safe.chainId,
createdTx.metadata,
activeWallet.address as Address
)
createSpinner.stop()
// Show transaction hash
console.log('')
console.log('✓ Transaction created successfully!')
console.log('')
console.log(` Safe TX Hash: ${createdTx.safeTxHash}`)
console.log('')
// Offer to sign the transaction
const shouldSign = await p.confirm({
message: 'Would you like to sign this transaction now?',
initialValue: true,
})
if (!p.isCancel(shouldSign) && shouldSign) {
console.log('')
const { signTransaction } = await import('./sign.js')
await signTransaction(createdTx.safeTxHash)
} else {
// Show full success screen with next steps
await renderScreen(TransactionCreateSuccessScreen, {
safeTxHash: createdTx.safeTxHash,
})
}
} catch (error) {
if (error instanceof SafeCLIError) {
p.log.error(error.message)
} else {
p.log.error(`Unexpected error: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
p.outro('Failed')
}
}