diff --git a/src/commands/account/change-threshold.ts b/src/commands/account/change-threshold.ts index cd137fc..5759671 100644 --- a/src/commands/account/change-threshold.ts +++ b/src/commands/account/change-threshold.ts @@ -88,14 +88,38 @@ export async function changeThreshold(account?: string) { return } - // Check if wallet is an owner - if (!safe.owners || !safe.threshold) { - p.log.error('Safe owner information not available. Please sync Safe data.') + // Get chain + const chain = configStore.getChain(safe.chainId) + if (!chain) { + p.log.error(`Chain ${safe.chainId} not found in configuration`) + p.outro('Failed') + return + } + + // Fetch live owners and threshold from blockchain + const spinner = p.spinner() + spinner.start('Fetching Safe information from blockchain...') + + let owners: Address[] + let currentThreshold: number + try { + const txService = new TransactionService(chain) + ;[owners, currentThreshold] = await Promise.all([ + txService.getOwners(safe.address as Address), + txService.getThreshold(safe.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 } - if (!safe.owners.some((owner) => owner.toLowerCase() === activeWallet.address.toLowerCase())) { + // 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 @@ -103,17 +127,16 @@ export async function changeThreshold(account?: string) { // Ask for new threshold const newThreshold = await p.text({ - message: `New threshold (current: ${safe.threshold}, max: ${safe.owners.length}):`, - placeholder: `${safe.threshold}`, + message: `New threshold (current: ${currentThreshold}, max: ${owners.length}):`, + placeholder: `${currentThreshold}`, validate: (value) => { if (!value) return 'Threshold is required' const num = parseInt(value, 10) if (isNaN(num) || num < 1) return 'Threshold must be at least 1' - if (!safe.owners || !safe.threshold) return 'Safe data not available' - if (num > safe.owners.length) { - return `Threshold cannot exceed ${safe.owners.length} (current owners)` + if (num > owners.length) { + return `Threshold cannot exceed ${owners.length} (current owners)` } - if (num === safe.threshold) { + if (num === currentThreshold) { return 'New threshold must be different from current threshold' } return undefined @@ -131,8 +154,8 @@ export async function changeThreshold(account?: string) { console.log('') console.log(pc.bold('Change Threshold Summary:')) console.log(` ${pc.dim('Safe:')} ${safe.name}`) - console.log(` ${pc.dim('Owners:')} ${safe.owners.length}`) - console.log(` ${pc.dim('Old Threshold:')} ${safe.threshold}`) + console.log(` ${pc.dim('Owners:')} ${owners.length}`) + console.log(` ${pc.dim('Old Threshold:')} ${currentThreshold}`) console.log(` ${pc.dim('New Threshold:')} ${thresholdNum}`) console.log('') @@ -146,16 +169,8 @@ export async function changeThreshold(account?: string) { return } - // Get chain - const chain = configStore.getChain(safe.chainId) - if (!chain) { - p.log.error(`Chain ${safe.chainId} not found in configuration`) - p.outro('Failed') - return - } - - const spinner = p.spinner() - spinner.start('Creating change threshold transaction...') + const spinner2 = p.spinner() + spinner2.start('Creating change threshold transaction...') // Create the change threshold transaction using Safe SDK const txService = new TransactionService(chain) @@ -174,13 +189,13 @@ export async function changeThreshold(account?: string) { activeWallet.address as Address ) - spinner.stop('Transaction created') + spinner2.stop('Transaction created') await renderScreen(ThresholdChangeSuccessScreen, { safeTxHash: safeTransaction.safeTxHash, safeAddress: safe.address as Address, chainId: safe.chainId, - oldThreshold: safe.threshold, + oldThreshold: currentThreshold, newThreshold: thresholdNum, }) } catch (error) { diff --git a/src/commands/account/remove-owner.ts b/src/commands/account/remove-owner.ts index 03d2b34..618ddb6 100644 --- a/src/commands/account/remove-owner.ts +++ b/src/commands/account/remove-owner.ts @@ -88,21 +88,45 @@ export async function removeOwner(account?: string) { return } - // Check if wallet is an owner - if (!safe.owners || !safe.threshold) { - p.log.error('Safe owner information not available. Please sync Safe data.') + // Get chain + const chain = configStore.getChain(safe.chainId) + if (!chain) { + p.log.error(`Chain ${safe.chainId} not found in configuration`) + p.outro('Failed') + return + } + + // Fetch live owners and threshold from blockchain + const spinner = p.spinner() + spinner.start('Fetching Safe information from blockchain...') + + let owners: Address[] + let currentThreshold: number + try { + const txService = new TransactionService(chain) + ;[owners, currentThreshold] = await Promise.all([ + txService.getOwners(safe.address as Address), + txService.getThreshold(safe.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 } - if (!safe.owners.some((owner) => owner.toLowerCase() === activeWallet.address.toLowerCase())) { + // 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 } // Check that Safe has at least 2 owners - if (safe.owners.length <= 1) { + if (owners.length <= 1) { p.log.error('Cannot remove the last owner from a Safe') p.outro('Failed') return @@ -111,7 +135,7 @@ export async function removeOwner(account?: string) { // Select owner to remove const ownerToRemove = await p.select({ message: 'Select owner to remove:', - options: safe.owners.map((owner) => ({ + options: owners.map((owner) => ({ value: owner, label: owner, })), @@ -125,10 +149,9 @@ export async function removeOwner(account?: string) { const removeAddress = ownerToRemove as Address // Calculate max threshold after removal - const maxThreshold = safe.owners.length - 1 + const maxThreshold = owners.length - 1 // Ask about threshold - const currentThreshold = safe.threshold const suggestedThreshold = Math.min(currentThreshold, maxThreshold) const newThreshold = await p.text({ @@ -158,9 +181,9 @@ export async function removeOwner(account?: string) { console.log(pc.bold('Remove Owner Summary:')) console.log(` ${pc.dim('Safe:')} ${safe.name}`) console.log(` ${pc.dim('Remove Owner:')} ${removeAddress}`) - console.log(` ${pc.dim('Current Owners:')} ${safe.owners.length}`) - console.log(` ${pc.dim('New Owners:')} ${safe.owners.length - 1}`) - console.log(` ${pc.dim('Old Threshold:')} ${safe.threshold}`) + console.log(` ${pc.dim('Current Owners:')} ${owners.length}`) + console.log(` ${pc.dim('New Owners:')} ${owners.length - 1}`) + console.log(` ${pc.dim('Old Threshold:')} ${currentThreshold}`) console.log(` ${pc.dim('New Threshold:')} ${thresholdNum}`) console.log('') @@ -174,16 +197,8 @@ export async function removeOwner(account?: string) { return } - // Get chain - const chain = configStore.getChain(safe.chainId) - if (!chain) { - p.log.error(`Chain ${safe.chainId} not found in configuration`) - p.outro('Failed') - return - } - - const spinner = p.spinner() - spinner.start('Creating remove owner transaction...') + const spinner2 = p.spinner() + spinner2.start('Creating remove owner transaction...') // Create the remove owner transaction using Safe SDK const txService = new TransactionService(chain) @@ -203,13 +218,13 @@ export async function removeOwner(account?: string) { activeWallet.address as Address ) - spinner.stop('Transaction created') + spinner2.stop('Transaction created') await renderScreen(OwnerRemoveSuccessScreen, { safeTxHash: safeTransaction.safeTxHash, safeAddress: safe.address as Address, chainId: safe.chainId, - threshold: safe.threshold, + threshold: currentThreshold, }) } catch (error) { if (error instanceof SafeCLIError) { diff --git a/src/commands/config/chains.ts b/src/commands/config/chains.ts index d3656f4..194849b 100644 --- a/src/commands/config/chains.ts +++ b/src/commands/config/chains.ts @@ -95,6 +95,20 @@ export async function addChain() { return } + const transactionServiceUrl = await p.text({ + message: 'Safe Transaction Service URL (optional):', + placeholder: 'https://safe-transaction-mainnet.safe.global', + validate: (value) => { + if (value && !isValidUrl(value)) return 'Invalid URL' + return undefined + }, + }) + + if (p.isCancel(transactionServiceUrl)) { + p.cancel('Operation cancelled') + return + } + const chainConfig: ChainConfig = { name: name as string, chainId: chainId as string, @@ -102,6 +116,7 @@ export async function addChain() { rpcUrl: rpcUrl as string, explorer: explorer ? (explorer as string) : undefined, currency: currency as string, + transactionServiceUrl: transactionServiceUrl ? (transactionServiceUrl as string) : undefined, } const spinner = p.spinner() diff --git a/src/commands/config/edit.ts b/src/commands/config/edit.ts index fa5070e..6291229 100644 --- a/src/commands/config/edit.ts +++ b/src/commands/config/edit.ts @@ -31,6 +31,8 @@ export async function editChains() { rpcUrl: 'RPC endpoint URL', currency: 'Native currency symbol (e.g., "ETH")', explorer: '(Optional) Block explorer base URL', + transactionServiceUrl: '(Optional) Safe Transaction Service API URL', + contractNetworks: '(Optional) Safe contract addresses for this chain', }, chains, } @@ -166,6 +168,8 @@ export async function editChains() { rpcUrl: c.rpcUrl, currency: c.currency, explorer: c.explorer, + transactionServiceUrl: c.transactionServiceUrl, + contractNetworks: c.contractNetworks, }) } diff --git a/src/commands/tx/create.ts b/src/commands/tx/create.ts index 4caeafa..96079ca 100644 --- a/src/commands/tx/create.ts +++ b/src/commands/tx/create.ts @@ -69,14 +69,34 @@ export async function createTransaction() { return } - // Check if wallet is an owner - if (!safe.owners) { - p.log.error('Safe owner information not available. Please sync Safe data.') + // Get chain + const chain = configStore.getChain(chainId) + if (!chain) { + p.log.error(`Chain ${chainId} not found in configuration`) p.outro('Failed') return } - if (!safe.owners.some((owner) => owner.toLowerCase() === activeWallet.address.toLowerCase())) { + // 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 @@ -108,28 +128,20 @@ export async function createTransaction() { return } - // Get chain for contract detection - const chain = configStore.getChain(safe.chainId) - if (!chain) { - p.log.error(`Chain ${safe.chainId} not found in configuration`) - 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 spinner = p.spinner() - spinner.start('Checking if address is a contract...') + const spinner2 = p.spinner() + spinner2.start('Checking if address is a contract...') try { isContract = await contractService.isContract(to) - spinner.stop(isContract ? 'Contract detected' : 'EOA (regular address)') + spinner2.stop(isContract ? 'Contract detected' : 'EOA (regular address)') } catch (error) { - spinner.stop('Failed to check contract') + spinner2.stop('Failed to check contract') p.log.warning('Could not determine if address is a contract, falling back to manual input') } diff --git a/src/commands/tx/execute.ts b/src/commands/tx/execute.ts index 6961663..2831329 100644 --- a/src/commands/tx/execute.ts +++ b/src/commands/tx/execute.ts @@ -51,7 +51,7 @@ export async function executeTransaction(safeTxHash?: string) { return { value: tx.safeTxHash, label: `${tx.safeTxHash.slice(0, 10)}... → ${tx.metadata.to}`, - hint: `Safe: ${safe?.name || eip3770} | Signatures: ${tx.signatures.length}`, + hint: `Safe: ${safe?.name || eip3770} | Signatures: ${tx.signatures?.length || 0}`, } }), })) as string @@ -89,24 +89,47 @@ export async function executeTransaction(safeTxHash?: string) { return } - // Check if wallet is an owner - if (!safe.owners || !safe.threshold) { - p.log.error('Safe owner information not available. Please sync Safe data.') + // Get chain + const chain = configStore.getChain(transaction.chainId) + if (!chain) { + p.log.error(`Chain ${transaction.chainId} not found in configuration`) p.outro('Failed') return } - if (!safe.owners.some((owner) => owner.toLowerCase() === activeWallet.address.toLowerCase())) { + // Fetch live owners and threshold from blockchain + const spinner = p.spinner() + spinner.start('Fetching Safe information from blockchain...') + + let owners: Address[] + let threshold: number + try { + const txService = new TransactionService(chain) + ;[owners, threshold] = await Promise.all([ + txService.getOwners(transaction.safeAddress), + txService.getThreshold(transaction.safeAddress), + ]) + 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 } // Check if we have enough signatures - if (transaction.signatures.length < safe.threshold) { - p.log.error( - `Not enough signatures. Have ${transaction.signatures.length}, need ${safe.threshold}` - ) + const sigCount = transaction.signatures?.length || 0 + if (sigCount < threshold) { + p.log.error(`Not enough signatures. Have ${sigCount}, need ${threshold}`) p.outro('Failed') return } @@ -117,7 +140,7 @@ export async function executeTransaction(safeTxHash?: string) { console.log(` Value: ${transaction.metadata.value} wei`) console.log(` Data: ${transaction.metadata.data}`) console.log(` Operation: ${transaction.metadata.operation === 0 ? 'Call' : 'DelegateCall'}`) - console.log(` Signatures: ${transaction.signatures.length}/${safe.threshold}`) + console.log(` Signatures: ${sigCount}/${threshold}`) const confirm = await p.confirm({ message: 'Execute this transaction on-chain?', @@ -144,34 +167,26 @@ export async function executeTransaction(safeTxHash?: string) { } // Get private key - const spinner = p.spinner() - spinner.start('Executing transaction') + const spinner2 = p.spinner() + spinner2.start('Executing transaction') let privateKey: string try { privateKey = walletStorage.getPrivateKey(activeWallet.id, password) } catch (error) { - spinner.stop('Failed') + spinner2.stop('Failed') p.log.error('Invalid password') p.outro('Failed') return } // Execute transaction - const chain = configStore.getChain(transaction.chainId) - if (!chain) { - spinner.stop('Failed') - p.log.error(`Chain ${transaction.chainId} not found in configuration`) - p.outro('Failed') - return - } - const txService = new TransactionService(chain, privateKey) const txHash = await txService.executeTransaction( transaction.safeAddress, transaction.metadata, - transaction.signatures.map((sig) => ({ + (transaction.signatures || []).map((sig) => ({ signer: sig.signer, signature: sig.signature, })) @@ -180,7 +195,7 @@ export async function executeTransaction(safeTxHash?: string) { // Update transaction status transactionStore.updateStatus(selectedSafeTxHash, TransactionStatus.EXECUTED, txHash) - spinner.stop('Transaction executed') + spinner2.stop('Transaction executed') const explorerUrl = chain.explorer ? `${chain.explorer}/tx/${txHash}` : undefined diff --git a/src/commands/tx/export.ts b/src/commands/tx/export.ts index 98fff13..7c09650 100644 --- a/src/commands/tx/export.ts +++ b/src/commands/tx/export.ts @@ -33,7 +33,7 @@ export async function exportTransaction(safeTxHash?: string, outputFile?: string options: transactions.map((tx) => ({ value: tx.safeTxHash, label: `${tx.safeTxHash.slice(0, 10)}... → ${tx.metadata.to}`, - hint: `${tx.signatures.length} signature(s)`, + hint: `${tx.signatures?.length || 0} signature(s)`, })), }) diff --git a/src/commands/tx/import.ts b/src/commands/tx/import.ts index 795b476..25cd372 100644 --- a/src/commands/tx/import.ts +++ b/src/commands/tx/import.ts @@ -317,9 +317,9 @@ export async function importTransaction(input?: string) { p.log.warning('Transaction already exists locally') // Merge signatures - const newSignatures = importData.signatures.filter( + const newSignatures = (importData.signatures || []).filter( (importSig) => - !existingTx.signatures.some( + !(existingTx.signatures || []).some( (existingSig) => existingSig.signer.toLowerCase() === importSig.signer.toLowerCase() ) ) @@ -347,14 +347,16 @@ export async function importTransaction(input?: string) { const updatedTx = transactionStore.getTransaction(importData.safeTxHash)! const readyToExecute = - safe && safe.threshold !== undefined && updatedTx.signatures.length >= safe.threshold + safe && + safe.threshold !== undefined && + (updatedTx.signatures?.length || 0) >= safe.threshold await renderScreen(TransactionImportSuccessScreen, { safeTxHash: importData.safeTxHash, safe: importData.safe || importData.safeAddress, to: importData.metadata.to, mode: 'merged' as const, - signatureCount: updatedTx.signatures.length, + signatureCount: updatedTx.signatures?.length || 0, threshold: safe?.threshold, newSigners: newSignatures.map((sig) => sig.signer as Address), readyToExecute: !!readyToExecute, @@ -386,14 +388,16 @@ export async function importTransaction(input?: string) { } const readyToExecute = - safe && safe.threshold !== undefined && importData.signatures.length >= safe.threshold + safe && + safe.threshold !== undefined && + (importData.signatures?.length || 0) >= safe.threshold await renderScreen(TransactionImportSuccessScreen, { safeTxHash: importData.safeTxHash, safe: importData.safe || importData.safeAddress, to: importData.metadata.to, mode: 'new' as const, - signatureCount: importData.signatures.length, + signatureCount: importData.signatures?.length || 0, threshold: safe?.threshold, readyToExecute: !!readyToExecute, }) diff --git a/src/commands/tx/pull.ts b/src/commands/tx/pull.ts index 189eb98..0393685 100644 --- a/src/commands/tx/pull.ts +++ b/src/commands/tx/pull.ts @@ -164,7 +164,9 @@ export async function pullTransactions(account?: string) { imported++ } else { // Merge signatures - const localSigners = new Set(localTx.signatures.map((sig) => sig.signer.toLowerCase())) + const localSigners = new Set( + (localTx.signatures || []).map((sig) => sig.signer.toLowerCase()) + ) const newSignatures = (remoteTx.confirmations || []).filter( (conf: any) => !localSigners.has(conf.owner.toLowerCase()) diff --git a/src/commands/tx/push.ts b/src/commands/tx/push.ts index 85b6dbd..5ba13a6 100644 --- a/src/commands/tx/push.ts +++ b/src/commands/tx/push.ts @@ -49,7 +49,7 @@ export async function pushTransaction(safeTxHash?: string) { return { value: tx.safeTxHash, label: `${tx.safeTxHash.slice(0, 10)}... → ${tx.metadata.to}`, - hint: `Safe: ${safe?.name || eip3770} | Signatures: ${tx.signatures.length}`, + hint: `Safe: ${safe?.name || eip3770} | Signatures: ${tx.signatures?.length || 0}`, } }), })) as string @@ -90,7 +90,7 @@ export async function pushTransaction(safeTxHash?: string) { } // Check if active wallet has signed - const walletSignature = transaction.signatures.find( + const walletSignature = (transaction.signatures || []).find( (sig) => sig.signer.toLowerCase() === activeWallet.address.toLowerCase() ) @@ -124,7 +124,7 @@ export async function pushTransaction(safeTxHash?: string) { const remoteSignatures = existingTx.confirmations || [] const remoteSigners = new Set(remoteSignatures.map((conf: any) => conf.owner.toLowerCase())) - const newSignatures = transaction.signatures.filter( + const newSignatures = (transaction.signatures || []).filter( (sig) => !remoteSigners.has(sig.signer.toLowerCase()) ) @@ -153,7 +153,7 @@ export async function pushTransaction(safeTxHash?: string) { pushedSigners.push(activeWallet.address as Address) // Add additional signatures if any - const additionalSignatures = transaction.signatures.filter( + const additionalSignatures = (transaction.signatures || []).filter( (sig) => sig.signer.toLowerCase() !== activeWallet.address.toLowerCase() ) @@ -163,13 +163,13 @@ export async function pushTransaction(safeTxHash?: string) { } } - const serviceUrl = `${chain.transactionServiceUrl}/api/v1/safes/${transaction.safeAddress}/multisig-transactions/${selectedSafeTxHash}/` const chains = configStore.getAllChains() const eip3770 = formatSafeAddress( transaction.safeAddress as Address, transaction.chainId, chains ) + const serviceUrl = `https://app.safe.global/transactions/tx?safe=${chain.shortName}:${transaction.safeAddress}&id=${selectedSafeTxHash}` await renderScreen(TransactionPushSuccessScreen, { safeTxHash: selectedSafeTxHash, diff --git a/src/commands/tx/sign.ts b/src/commands/tx/sign.ts index 9e64098..63a8670 100644 --- a/src/commands/tx/sign.ts +++ b/src/commands/tx/sign.ts @@ -53,7 +53,7 @@ export async function signTransaction(safeTxHash?: string) { return { value: tx.safeTxHash, label: `${tx.safeTxHash.slice(0, 10)}... → ${tx.metadata.to}`, - hint: `Safe: ${safe?.name || eip3770} | Signatures: ${tx.signatures.length}`, + hint: `Safe: ${safe?.name || eip3770} | Signatures: ${tx.signatures?.length || 0}`, } }), })) as string @@ -91,21 +91,45 @@ export async function signTransaction(safeTxHash?: string) { return } - // Check if wallet is an owner - if (!safe.owners || !safe.threshold) { - p.log.error('Safe owner information not available. Please sync Safe data.') + // Get chain + const chain = configStore.getChain(transaction.chainId) + if (!chain) { + p.log.error(`Chain ${transaction.chainId} not found in configuration`) p.outro('Failed') return } - if (!safe.owners.some((owner) => owner.toLowerCase() === activeWallet.address.toLowerCase())) { + // Fetch live owners and threshold from blockchain + const spinner = p.spinner() + spinner.start('Fetching Safe information from blockchain...') + + let owners: Address[] + let threshold: number + try { + const txService = new TransactionService(chain) + ;[owners, threshold] = await Promise.all([ + txService.getOwners(transaction.safeAddress), + txService.getThreshold(transaction.safeAddress), + ]) + 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 } // Check if already signed - const existingSignature = transaction.signatures.find( + const existingSignature = transaction.signatures?.find( (sig) => sig.signer.toLowerCase() === activeWallet.address.toLowerCase() ) @@ -136,28 +160,20 @@ export async function signTransaction(safeTxHash?: string) { } // Get private key - const spinner = p.spinner() - spinner.start('Signing transaction') + const spinner2 = p.spinner() + spinner2.start('Signing transaction') let privateKey: string try { privateKey = walletStorage.getPrivateKey(activeWallet.id, password) } catch (error) { - spinner.stop('Failed') + spinner2.stop('Failed') p.log.error('Invalid password') p.outro('Failed') return } // Sign transaction - const chain = configStore.getChain(transaction.chainId) - if (!chain) { - spinner.stop('Failed') - p.log.error(`Chain ${transaction.chainId} not found in configuration`) - p.outro('Failed') - return - } - const txService = new TransactionService(chain, privateKey) const signature = await txService.signTransaction(transaction.safeAddress, transaction.metadata) @@ -174,15 +190,14 @@ export async function signTransaction(safeTxHash?: string) { transactionStore.updateStatus(selectedSafeTxHash, TransactionStatus.SIGNED) } - spinner.stop('Transaction signed') + spinner2.stop('Transaction signed') // Check if we have enough signatures const updatedTx = transactionStore.getTransaction(selectedSafeTxHash)! - const threshold = safe.threshold await renderScreen(TransactionSignSuccessScreen, { safeTxHash: selectedSafeTxHash, - currentSignatures: updatedTx.signatures.length, + currentSignatures: updatedTx.signatures?.length || 0, requiredSignatures: threshold, }) } catch (error) { diff --git a/src/commands/tx/status.ts b/src/commands/tx/status.ts index 10b28ac..b41dfba 100644 --- a/src/commands/tx/status.ts +++ b/src/commands/tx/status.ts @@ -4,6 +4,7 @@ import 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 { TransactionService } from '../../services/transaction-service.js' import { SafeCLIError } from '../../utils/errors.js' import { formatSafeAddress } from '../../utils/eip3770.js' import { renderScreen } from '../../ui/render.js' @@ -64,17 +65,39 @@ export async function showTransactionStatus(safeTxHash?: string) { } const chain = configStore.getChain(tx.chainId) + if (!chain) { + p.log.error(`Chain ${tx.chainId} not found in configuration`) + p.outro('Failed') + return + } + const eip3770 = formatSafeAddress(tx.safeAddress, tx.chainId, chains) - if (!safe.owners || !safe.threshold) { - p.log.error('Safe owner information not available. Please sync Safe data.') + // Fetch live owners and threshold from blockchain + const spinner = p.spinner() + spinner.start('Fetching Safe information from blockchain...') + + let owners: Address[] + let threshold: number + try { + const txService = new TransactionService(chain) + ;[owners, threshold] = await Promise.all([ + txService.getOwners(tx.safeAddress), + txService.getThreshold(tx.safeAddress), + ]) + 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 } // Calculate signature status - const signaturesCollected = tx.signatures.length - const signaturesRequired = safe.threshold + const signaturesCollected = tx.signatures?.length || 0 + const signaturesRequired = threshold await renderScreen(TransactionStatusScreen, { safeTxHash: tx.safeTxHash, @@ -85,8 +108,8 @@ export async function showTransactionStatus(safeTxHash?: string) { status: tx.status, signaturesCollected, signaturesRequired, - signers: tx.signatures.map((sig) => sig.signer as Address), - owners: safe.owners as Address[], + signers: (tx.signatures || []).map((sig) => sig.signer as Address), + owners, txHash: tx.txHash, explorerUrl: tx.txHash && chain?.explorer ? `${chain.explorer}/tx/${tx.txHash}` : undefined, }) diff --git a/src/commands/tx/sync.ts b/src/commands/tx/sync.ts index f7ab4af..f447693 100644 --- a/src/commands/tx/sync.ts +++ b/src/commands/tx/sync.ts @@ -155,7 +155,9 @@ export async function syncTransactions(account?: string) { pullImported++ } else { - const localSigners = new Set(localTx.signatures.map((sig) => sig.signer.toLowerCase())) + const localSigners = new Set( + (localTx.signatures || []).map((sig) => sig.signer.toLowerCase()) + ) const newSignatures = (remoteTx.confirmations || []).filter( (conf: any) => !localSigners.has(conf.owner.toLowerCase()) ) @@ -198,7 +200,7 @@ export async function syncTransactions(account?: string) { for (const localTx of localTxs) { // Check if active wallet has signed - const walletSignature = localTx.signatures.find( + const walletSignature = (localTx.signatures || []).find( (sig) => sig.signer.toLowerCase() === activeWallet.address.toLowerCase() ) @@ -216,7 +218,7 @@ export async function syncTransactions(account?: string) { remoteSignatures.map((conf: any) => conf.owner.toLowerCase()) ) - const newSignatures = localTx.signatures.filter( + const newSignatures = (localTx.signatures || []).filter( (sig) => !remoteSigners.has(sig.signer.toLowerCase()) ) @@ -237,7 +239,7 @@ export async function syncTransactions(account?: string) { ) // Add additional signatures - const additionalSignatures = localTx.signatures.filter( + const additionalSignatures = (localTx.signatures || []).filter( (sig) => sig.signer.toLowerCase() !== activeWallet.address.toLowerCase() ) diff --git a/src/services/transaction-service.ts b/src/services/transaction-service.ts index e364330..c654bc8 100644 --- a/src/services/transaction-service.ts +++ b/src/services/transaction-service.ts @@ -104,9 +104,9 @@ export class TransactionService { transactions: [ { to: metadata.to, - value: metadata.value, - data: metadata.data, - operation: metadata.operation, + value: metadata.value || '0', + data: metadata.data || '0x', + operation: metadata.operation || 0, }, ], // Use the original nonce to ensure we sign the same transaction @@ -156,9 +156,9 @@ export class TransactionService { transactions: [ { to: metadata.to, - value: metadata.value, - data: metadata.data, - operation: metadata.operation, + value: metadata.value || '0', + data: metadata.data || '0x', + operation: metadata.operation || 0, }, ], // Use the original nonce to ensure we execute the same transaction diff --git a/src/storage/transaction-store.ts b/src/storage/transaction-store.ts index b12caa5..3014206 100644 --- a/src/storage/transaction-store.ts +++ b/src/storage/transaction-store.ts @@ -50,23 +50,34 @@ export class TransactionStore { return transaction } + private normalizeTransaction(tx: StoredTransaction): StoredTransaction { + // Ensure signatures array is initialized + if (!tx.signatures) { + tx.signatures = [] + } + return tx + } + getTransaction(safeTxHash: string): StoredTransaction | undefined { const transactions = this.store.get('transactions') - return transactions[safeTxHash] + const tx = transactions[safeTxHash] + return tx ? this.normalizeTransaction(tx) : undefined } getTransactionsBySafe(safeAddress: Address, chainId?: string): StoredTransaction[] { const transactions = this.store.get('transactions') - return Object.values(transactions).filter( - (tx) => - tx.safeAddress.toLowerCase() === safeAddress.toLowerCase() && - (!chainId || tx.chainId === chainId) - ) + return Object.values(transactions) + .filter( + (tx) => + tx.safeAddress.toLowerCase() === safeAddress.toLowerCase() && + (!chainId || tx.chainId === chainId) + ) + .map((tx) => this.normalizeTransaction(tx)) } getAllTransactions(): StoredTransaction[] { const transactions = this.store.get('transactions') - return Object.values(transactions) + return Object.values(transactions).map((tx) => this.normalizeTransaction(tx)) } addSignature(safeTxHash: string, signature: TransactionSignature): void { @@ -77,6 +88,11 @@ export class TransactionStore { throw new SafeCLIError(`Transaction ${safeTxHash} not found`) } + // Initialize signatures array if it's null or undefined + if (!transaction.signatures) { + transaction.signatures = [] + } + // Check if this signer has already signed const existingSignatureIndex = transaction.signatures.findIndex( (sig) => sig.signer.toLowerCase() === signature.signer.toLowerCase() diff --git a/src/ui/screens/TransactionListScreen.tsx b/src/ui/screens/TransactionListScreen.tsx index 57e6dfb..173e8af 100644 --- a/src/ui/screens/TransactionListScreen.tsx +++ b/src/ui/screens/TransactionListScreen.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { Box, Text } from 'ink' import type { Address } from 'viem' import { useTransactions, useTransactionsBySafe } from '../hooks/index.js' @@ -8,6 +8,7 @@ import type { StoredTransaction, TransactionStatus } from '../../types/transacti import { getConfigStore } from '../../storage/config-store.js' import { getSafeStorage } from '../../storage/safe-store.js' import { formatSafeAddress } from '../../utils/eip3770.js' +import { TransactionService } from '../../services/transaction-service.js' export interface TransactionListScreenProps { /** @@ -60,6 +61,7 @@ function TransactionItem({ transaction }: TransactionItemProps): React.ReactElem const configStore = getConfigStore() const safeStorage = getSafeStorage() const chains = configStore.getAllChains() + const [threshold, setThreshold] = useState(undefined) const safe = safeStorage.getSafe(transaction.chainId, transaction.safeAddress) const safeName = safe?.name || 'Unknown' @@ -68,6 +70,23 @@ function TransactionItem({ transaction }: TransactionItemProps): React.ReactElem const statusBadge = getStatusBadge(transaction.status) + // Fetch live threshold from blockchain + useEffect(() => { + if (!safe?.deployed || !chain) return + + const fetchThreshold = async () => { + try { + const txService = new TransactionService(chain) + const liveThreshold = await txService.getThreshold(transaction.safeAddress as Address) + setThreshold(liveThreshold) + } catch { + // Silently fail - threshold will remain undefined + } + } + + fetchThreshold() + }, [safe?.deployed, chain, transaction.safeAddress]) + return ( {/* Status badge */} @@ -92,7 +111,7 @@ function TransactionItem({ transaction }: TransactionItemProps): React.ReactElem }, { key: 'Signatures', - value: `${transaction.signatures.length}${safe ? `/${safe.threshold}` : ''}`, + value: `${transaction.signatures?.length || 0}${threshold !== undefined ? `/${threshold}` : ''}`, }, { key: 'Created', value: new Date(transaction.createdAt).toLocaleString() }, { key: 'Created by', value: transaction.createdBy }, diff --git a/src/ui/screens/TransactionPushSuccessScreen.tsx b/src/ui/screens/TransactionPushSuccessScreen.tsx index 3fe66cc..14257c5 100644 --- a/src/ui/screens/TransactionPushSuccessScreen.tsx +++ b/src/ui/screens/TransactionPushSuccessScreen.tsx @@ -26,7 +26,7 @@ export interface TransactionPushSuccessScreenProps { signers: Address[] /** - * Safe Transaction Service URL for viewing the transaction + * Safe Wallet app URL for viewing the transaction */ serviceUrl: string @@ -44,7 +44,7 @@ export interface TransactionPushSuccessScreenProps { * - Shows transaction hash and Safe * - Displays mode (proposed new or updated existing) * - Lists pushed signers - * - Shows Safe Transaction Service URL + * - Shows Safe Wallet app URL */ export function TransactionPushSuccessScreen({ safeTxHash, @@ -101,7 +101,7 @@ export function TransactionPushSuccessScreen({ {/* Service URL */} - View on Safe Transaction Service: + View in Safe Wallet: {serviceUrl}