diff --git a/README.md b/README.md index bb0da8f..d8c6e3a 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,25 @@ # Safe Multisig Transaction Hashes -This repository contains both a Bash script and a web interface for calculating Safe transaction hashes. It helps users verify transaction hashes before signing them on hardware wallets by retrieving transaction details from the Safe transaction service API and computing the domain and message hashes using the EIP-712 standard. +This repository contains a web interface for calculating Safe transaction hashes. It helps users verify transaction hashes before signing them on hardware wallets by retrieving transaction details from the Safe Transaction Service API and computing the domain and message hashes using the EIP‑712 standard. -The project is a fork of [@pcaversaccio](https://x.com/pcaversaccio) bash script, full details of such bash script README can be found at [its original reository](https://github.com/pcaversaccio/safe-tx-hashes-util/blob/main/README.md). +The UI also offers a second method to manually input transaction details instead of recovering them from Safe’s API. + +All processing happens in your browser. When using the API method, your browser fetches read‑only transaction data directly from the Safe Transaction Service. For increased security, we recommend running the app locally on a trusted device. + +This project is inspired by the `safe-tx-hashes-util` bash script developed by [@pcaversaccio](https://x.com/pcaversaccio). Full details can be found in its original repository: [github.com/pcaversaccio/safe-tx-hashes-util](https://github.com/pcaversaccio/safe-tx-hashes-util/blob/main/README.md). -The UI also offers a second method to manually input transaction details instead of recovering them from Safe's API. ## Disclaimer -This is a fork of a script by [@pcaversaccio](https://github.com/pcaversaccio/safe-tx-hashes-util) that adds a user interface. It has not been subject to any security assessment and is therefore not suitable for production use. Any use of the tool is at your own risk in accordance with our [Terms of Service](https://www.openzeppelin.com/tos). +Safe Utils has not been subject to any security assessment and is therefore not suitable for production use. Any use of the tool is at your own risk in accordance with our [Terms of Service](https://www.openzeppelin.com/tos). -This tool is intended to be used as a proof of concept and feedback and contributions are welcome. While there are few dependencies, you should always do your own investigation and [run the tool locally](https://github.com/openzeppelin/safe-utils?tab=readme-ov-file#run-locally) where possible. +This tool is intended to be used as a proof of concept, and feedback and contributions are welcome. While there are few dependencies, you should always do your own investigation and [run the tool locally](https://github.com/openzeppelin/safe-utils?tab=readme-ov-file#run-locally) where possible. ## Prerequisites Before you begin, ensure you have the following installed: -- Node.js (version 14 or later) +- Node.js 18.17+ (20+ recommended) - npm (usually comes with Node.js) ## Run locally @@ -28,36 +31,39 @@ Before you begin, ensure you have the following installed: cd safe-utils ``` -2. Set up the `safe_hashes.sh` script: - - Ensure the `safe_hashes.sh` script is located in the parent directory of the app. - - Make the script executable: - - ```bash - chmod +x ../safe_hashes.sh - ``` - -3. Install dependencies: +2. Install dependencies: ```bash cd app/ npm install ``` -4. Run the development server: +3. Run the development server: ```bash npm run dev ``` -5. Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +4. Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +5. Safe API Key usage (Optional) + If you have a Safe Transaction Service API key, you can use it to reduce the time between requests and improve reliability. This is optional — the app works without a key. + + Steps: + + - Copy the `.env.example` file to `.env` inside the `app/` directory. + - Set `SAFE_API_KEY` using a key obtained by following [Safe’s guide](https://docs.safe.global/core-api/how-to-use-api-keys). + Notes: + - Requests are made from your browser and include the header `Authorization: Bearer `. + - Without a key, the app automatically throttles requests to lower the chance of hitting rate limits, but may still encounter 429 responses. ## Usage For quick and easy access, you can use the hosted version of Safe Hash Preview at [https://safeutils.openzeppelin.com/](https://safeutils.openzeppelin.com/). This version is ready to use without any setup required. How to use the application: -- Choose the calculation method, defaults to Manual Input. Alternative you can use Safe's API which requires less input. +- Choose the calculation method, defaults to Manual Input. Alternatively, you can use Safe’s API which requires less input. - Select a network from the dropdown menu. - Enter the Safe address. - Fill the rest of the data according to your selected method. diff --git a/app/.env.example b/app/.env.example index dd3c23f..82b86cd 100644 --- a/app/.env.example +++ b/app/.env.example @@ -1,2 +1,4 @@ NEXT_PUBLIC_BASE_URL=http://localhost:3000 -NEXT_PUBLIC_BASE_PATH=/safe \ No newline at end of file +NEXT_PUBLIC_BASE_PATH=/safe +#https://docs.safe.global/core-api/how-to-use-api-keys +SAFE_API_KEY=your-api-key-here \ No newline at end of file diff --git a/app/app/api/calculate-hashes/route.ts b/app/app/api/calculate-hashes/route.ts deleted file mode 100644 index 290f62e..0000000 --- a/app/app/api/calculate-hashes/route.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { execFile } from 'child_process' -import util from 'util' -import path from 'path' -import { API_URLS, isValidEthereumAddress, isValidNetwork, isValidNonce } from '@/lib/utils' - -const execFilePromise = util.promisify(execFile) - -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url) - const network = searchParams.get('network') - const address = searchParams.get('address') - const nonce = searchParams.get('nonce') - - // Check for missing parameters - if (!network || !address || !nonce) { - return NextResponse.json({ - error: 'Missing required parameters', - details: 'network, address, and nonce are required' - }, { status: 400 }) - } - - // Validate network - if (!isValidNetwork(network)) { - return NextResponse.json({ - error: 'Invalid network', - details: `Network must be one of: ${Object.keys(API_URLS).join(', ')}` - }, { status: 400 }) - } - - // Validate address - if (!isValidEthereumAddress(address)) { - return NextResponse.json({ - error: 'Invalid address', - details: 'Address must be a valid Ethereum address (0x followed by 40 hex characters)' - }, { status: 400 }) - } - - // Validate nonce - if (!isValidNonce(nonce)) { - return NextResponse.json({ - error: 'Invalid nonce', - details: 'Nonce must be a positive integer between 0 and 1000000' - }, { status: 400 }) - } - - try { - const scriptPath = path.resolve(process.cwd(), '..', 'safe_hashes.sh') - - // Validate script path - if (!scriptPath.endsWith('safe_hashes.sh')) { - return NextResponse.json({ - error: 'Invalid script configuration', - details: 'Script path validation failed' - }, { status: 500 }) - } - - const { stdout, stderr } = await execFilePromise( - scriptPath, - ['--network', network, '--address', address, '--nonce', nonce, '--json'] - ) - - if (stderr) { - console.error('Script error:', stderr) - return NextResponse.json({ error: 'Error executing script' }, { status: 500 }) - } - - try { - const result = JSON.parse(stdout) - return NextResponse.json({ result }) - } catch (parseError) { - console.error('Error parsing script output:', parseError) - return NextResponse.json({ - error: 'Error parsing script output', - details: 'Failed to parse JSON response' - }, { status: 500 }) - } - } catch (error) { - console.error('Error:', error) - return NextResponse.json({ - error: 'An error occurred while processing the request', - details: error instanceof Error ? error.message : 'Unknown error' - }, { status: 500 }) - } -} diff --git a/app/app/constants.ts b/app/app/constants.ts index 3ceccc2..09ff219 100644 --- a/app/app/constants.ts +++ b/app/app/constants.ts @@ -1,236 +1,295 @@ +export const BASE_URL = "https://api.safe.global/tx-service"; + export const NETWORKS = [ - { - value: "ethereum", - label: "Ethereum", - chainId: 1, - gnosisPrefix: "eth", - logo: "networks/ethereum.ico", - }, - { - value: "arbitrum", - label: "Arbitrum", - chainId: 42161, - gnosisPrefix: "arb1", - logo: "networks/arbitrum.ico", - }, - { - value: "optimism", - label: "OP Mainnet", - chainId: 10, - gnosisPrefix: "oeth", - logo: "networks/optimism.ico", - }, - { - value: "base", - label: "Base", - chainId: 8453, - gnosisPrefix: "base", - logo: "networks/base.ico", - }, - { - value: "worldchain", - label: "Worldchain", - chainId: 10252, - gnosisPrefix: "wc", - logo: "networks/worldchain.ico", - }, - { - value: "unichain", - label: "Unichain", - chainId: 130, - gnosisPrefix: "unichain", - logo: "networks/unichain.ico", - }, - { - value: "berachain", - label: "Berachain", - chainId: 80094, - gnosisPrefix: "berachain", - logo: "networks/berachain.ico", - }, - { - value: "sonic", - label: "Sonic", - chainId: 146, - gnosisPrefix: "sonic", - logo: "networks/sonic.ico", - }, - { - value: "ink", - label: "Ink", - chainId: 57073, - gnosisPrefix: "ink", - logo: "networks/ink.ico", - }, - { - value: "mantle", - label: "Mantle", - chainId: 5000, - gnosisPrefix: "mnt", - logo: "networks/mantle.ico", - }, - { - value: "polygon", - label: "Polygon", - chainId: 137, - gnosisPrefix: "matic", - logo: "networks/polygon.ico", - }, - { - value: "bsc", - label: "BSC", - chainId: 56, - gnosisPrefix: "bnb", - logo: "networks/bsc.ico", - }, - { - value: "avalanche", - label: "Avalanche", - chainId: 43114, - gnosisPrefix: "avax", - logo: "networks/avalanche.ico", - }, - { - value: "celo", - label: "Celo", - chainId: 42220, - gnosisPrefix: "celo", - logo: "networks/celo.ico", - }, - { - value: "gnosis-chain", - label: "Gnosis Chain", - chainId: 100, - gnosisPrefix: "gno", - logo: "networks/gnosis.ico", - }, - { - value: "linea", - label: "Linea", - chainId: 59144, - gnosisPrefix: "linea", - logo: "networks/linea.ico", - }, - { - value: "zksync", - label: "zkSync", - chainId: 324, - gnosisPrefix: "zksync", - logo: "networks/zksync.ico", - }, - { - value: "polygon-zkevm", - label: "Polygon zkEVM", - chainId: 1101, - gnosisPrefix: "zkevm", - logo: "networks/polygon.ico", - }, - { - value: "scroll", - label: "Scroll", - chainId: 534352, - gnosisPrefix: "scr", - logo: "networks/scroll.ico", - }, - { - value: "xlayer", - label: "xLayer", - chainId: 204, - gnosisPrefix: "xlayer", - logo: "networks/xlayer.ico", - }, - { - value: "aurora", - label: "Aurora", - chainId: 1313161554, - gnosisPrefix: "aurora", - logo: "networks/aurora.ico", - }, - { - value: "blast", - label: "Blast", - chainId: 81457, - gnosisPrefix: "blast", - logo: "networks/blast.ico", - }, - { - value: "sepolia", - label: "Sepolia", - chainId: 11155111, - gnosisPrefix: "sep", - logo: "networks/ethereum.ico", - }, - { - value: "base-sepolia", - label: "Base Sepolia", - chainId: 84532, - gnosisPrefix: "basesep", - logo: "networks/base.ico", - }, - { - value: "chiado", - label: "Gnosis Chiado", - chainId: 10200, - gnosisPrefix: "chiado", - logo: "networks/gnosis.ico", - }, - { - value: "hemi", - label: "Hemi", - chainId: 43111, - gnosisPrefix: "hemi", - logo: "networks/hemi.ico", - }, - { - value: "lens", - label: "Lens", - chainId: 232, - gnosisPrefix: "lens", - logo: "networks/lens.ico", - }, - { - value: "katana", - label: "Katana", - chainId: 747474, - gnosisPrefix: "katana", - logo: "networks/katana.ico", - }, - { - value: "botanix", - label: "Botanix", - chainId: 3637, - gnosisPrefix: "botanix", - logo: "networks/botanix.ico", - }, - { - value: "codex", - label: "Codex", - chainId: 81224, - gnosisPrefix: "codex", - logo: "networks/codex.ico", - }, - { - value: "opbnb", - label: "opBNB", - chainId: 204, - gnosisPrefix: "opbnb", - logo: "networks/opbnb.ico", - }, - { - value: "peaq", - label: "Peaq", - chainId: 3338, - gnosisPrefix: "peaq", - logo: "networks/peaq.ico", - }, - { - value: "xdc", - label: "XDC", - chainId: 50, - gnosisPrefix: "xdc", - logo: "networks/xdc.ico", - }, - ]; + { + value: "ethereum", + label: "Ethereum", + chainId: 1, + gnosisPrefix: "eth", + logo: "networks/ethereum.ico", + apiUrl: `${BASE_URL}/eth`, + }, + { + value: "arbitrum", + label: "Arbitrum", + chainId: 42161, + gnosisPrefix: "arb1", + logo: "networks/arbitrum.ico", + apiUrl: `${BASE_URL}/arb1`, + }, + { + value: "base", + label: "Base", + chainId: 8453, + gnosisPrefix: "base", + logo: "networks/base.ico", + apiUrl: `${BASE_URL}/base`, + }, + { + value: "optimism", + label: "OP Mainnet", + chainId: 10, + gnosisPrefix: "oeth", + logo: "networks/optimism.ico", + apiUrl: `${BASE_URL}/oeth`, + }, + { + value: "worldchain", + label: "Worldchain", + chainId: 480, + gnosisPrefix: "wc", + logo: "networks/worldchain.ico", + apiUrl: `${BASE_URL}/wc`, + }, + { + value: "unichain", + label: "Unichain", + chainId: 130, + gnosisPrefix: "unichain", + logo: "networks/unichain.ico", + apiUrl: `${BASE_URL}/unichain`, + }, + { + value: "monad", + label: "Monad", + chainId: 143, + gnosisPrefix: "monad", + logo: "networks/monad.ico", + apiUrl: `${BASE_URL}/monad`, + }, + { + value: "avalanche", + label: "Avalanche", + chainId: 43114, + gnosisPrefix: "avax", + logo: "networks/avalanche.ico", + apiUrl: `${BASE_URL}/avax`, + }, + { + value: "berachain", + label: "Berachain", + chainId: 80094, + gnosisPrefix: "berachain", + logo: "networks/berachain.ico", + apiUrl: `${BASE_URL}/berachain`, + }, + { + value: "linea", + label: "Linea", + chainId: 59144, + gnosisPrefix: "linea", + logo: "networks/linea.ico", + apiUrl: `${BASE_URL}/linea`, + }, + { + value: "zksync", + label: "zkSync", + chainId: 324, + gnosisPrefix: "zksync", + logo: "networks/zksync.ico", + apiUrl: `${BASE_URL}/zksync`, + }, + { + value: "plasma", + label: "Plasma", + chainId: 9745, + gnosisPrefix: "plasma", + logo: "networks/plasma.ico", + apiUrl: `${BASE_URL}/plasma`, + }, + { + value: "polygon", + label: "Polygon", + chainId: 137, + gnosisPrefix: "matic", + logo: "networks/polygon.ico", + apiUrl: `${BASE_URL}/pol`, + }, + { + value: "0g", + label: "0g", + chainId: 16661, + gnosisPrefix: "0g", + logo: "networks/0g.ico", + apiUrl: `${BASE_URL}/0g`, + }, + { + value: "aurora", + label: "Aurora", + chainId: 1313161554, + gnosisPrefix: "aurora", + logo: "networks/aurora.ico", + apiUrl: `${BASE_URL}/aurora`, + }, + { + value: "base-sepolia", + label: "Base Sepolia", + chainId: 84532, + gnosisPrefix: "basesep", + logo: "networks/base.ico", + apiUrl: `${BASE_URL}/basesep`, + }, + { + value: "blast", + label: "Blast", + chainId: 81457, + gnosisPrefix: "blast", + logo: "networks/blast.ico", + apiUrl: `https://safe-transaction-blast.safe.global`, + }, + { + value: "botanix", + label: "Botanix", + chainId: 3637, + gnosisPrefix: "botanix", + logo: "networks/botanix.ico", + apiUrl: `${BASE_URL}/btc`, + }, + { + value: "bsc", + label: "BSC", + chainId: 56, + gnosisPrefix: "bnb", + logo: "networks/bsc.ico", + apiUrl: `${BASE_URL}/bnb`, + }, + { + value: "celo", + label: "Celo", + chainId: 42220, + gnosisPrefix: "celo", + logo: "networks/celo.ico", + apiUrl: `${BASE_URL}/celo`, + }, + { + value: "codex", + label: "Codex", + chainId: 81224, + gnosisPrefix: "codex", + logo: "networks/codex.ico", + apiUrl: `${BASE_URL}/codex`, + }, + { + value: "gnosis-chain", + label: "Gnosis Chain", + chainId: 100, + gnosisPrefix: "gno", + logo: "networks/gnosis.ico", + apiUrl: `${BASE_URL}/gno`, + }, + { + value: "chiado", + label: "Gnosis Chiado", + chainId: 10200, + gnosisPrefix: "chiado", + logo: "networks/gnosis.ico", + apiUrl: `${BASE_URL}/chi`, + }, + { + value: "hemi", + label: "Hemi", + chainId: 43111, + gnosisPrefix: "hemi", + logo: "networks/hemi.ico", + apiUrl: `${BASE_URL}/hemi`, + }, + { + value: "ink", + label: "Ink", + chainId: 57073, + gnosisPrefix: "ink", + logo: "networks/ink.ico", + apiUrl: `${BASE_URL}/ink`, + }, + { + value: "katana", + label: "Katana", + chainId: 747474, + gnosisPrefix: "katana", + logo: "networks/katana.ico", + apiUrl: `${BASE_URL}/katana`, + }, + { + value: "lens", + label: "Lens", + chainId: 232, + gnosisPrefix: "lens", + logo: "networks/lens.ico", + apiUrl: `${BASE_URL}/lens`, + }, + { + value: "mantle", + label: "Mantle", + chainId: 5000, + gnosisPrefix: "mnt", + logo: "networks/mantle.ico", + apiUrl: `${BASE_URL}/mantle`, + }, + { + value: "opbnb", + label: "opBNB", + chainId: 204, + gnosisPrefix: "opbnb", + logo: "networks/opbnb.ico", + apiUrl: `${BASE_URL}/opbnb`, + }, + { + value: "peaq", + label: "Peaq", + chainId: 3338, + gnosisPrefix: "peaq", + logo: "networks/peaq.ico", + apiUrl: `${BASE_URL}/peaq`, + }, + { + value: "polygon-zkevm", + label: "Polygon zkEVM", + chainId: 1101, + gnosisPrefix: "zkevm", + logo: "networks/polygon.ico", + apiUrl: `${BASE_URL}/zkevm`, + }, + { + value: "scroll", + label: "Scroll", + chainId: 534352, + gnosisPrefix: "scr", + logo: "networks/scroll.ico", + apiUrl: `${BASE_URL}/scr`, + }, + { + value: "sepolia", + label: "Sepolia", + chainId: 11155111, + gnosisPrefix: "sep", + logo: "networks/ethereum.ico", + apiUrl: `${BASE_URL}/sep`, + }, + { + value: "sonic", + label: "Sonic", + chainId: 146, + gnosisPrefix: "sonic", + logo: "networks/sonic.ico", + apiUrl: `${BASE_URL}/sonic`, + }, + { + value: "xdc", + label: "XDC", + chainId: 50, + gnosisPrefix: "xdc", + logo: "networks/xdc.ico", + apiUrl: `${BASE_URL}/xdc`, + }, + { + value: "xlayer", + label: "xLayer", + chainId: 196, + gnosisPrefix: "xlayer", + logo: "networks/xlayer.ico", + apiUrl: `${BASE_URL}/okb`, + } +]; export const SAFE_VERSIONS = [ @@ -241,7 +300,8 @@ export const SAFE_VERSIONS = [ "1.1.1", "1.2.0", "1.3.0", - "1.4.1" + "1.4.1", + "1.5.0", ]; export const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; diff --git a/app/components/result/trusted-addresses.tsx b/app/components/result/trusted-addresses.tsx index e9ec817..07869f0 100644 --- a/app/components/result/trusted-addresses.tsx +++ b/app/components/result/trusted-addresses.tsx @@ -5,14 +5,17 @@ export const trustedAddresses = [ "0x998739BFdAAdde7C933B942a68053933098f9EDa", // MultiSend `v1.3.0` (eip155). "0x0dFcccB95225ffB03c6FBB2559B530C2B7C8A912", // MultiSend `v1.3.0` (zksync). "0x38869bf66a61cF6bDB996A6aE40D5853Fd43B526", // MultiSend `v1.4.1` (canonical). + "0xA83c336B20401Af773B6219BA5027174338D1836", // MultiSendCallOnly `v1.5.0` (canonical). "0x40A2aCCbd92BCA938b02010E17A5b8929b49130D", // MultiSendCallOnly `v1.3.0` (canonical). "0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B", // MultiSendCallOnly `v1.3.0` (eip155). "0xf220D3b4DFb23C4ade8C88E526C1353AbAcbC38F", // MultiSendCallOnly `v1.3.0` (zksync). "0x9641d764fc13c8B624c04430C7356C1C7C8102e2", // MultiSendCallOnly `v1.4.1` (canonical). "0x526643F69b81B008F46d95CD5ced5eC0edFFDaC6", // SafeMigration `v1.4.1` (canonical). + "0x6439e7ABD8Bb915A5263094784C5CF561c4172AC", // SafeMigration `v1.5.0` (canonical). "0xfF83F6335d8930cBad1c0D439A841f01888D9f69", // SafeToL2Migration `v1.4.1` (canonical). "0xA65387F16B013cf2Af4605Ad8aA5ec25a2cbA3a2", // SignMessageLib `v1.3.0` (canonical). "0x98FFBBF51bb33A056B08ddf711f289936AafF717", // SignMessageLib `v1.3.0` (eip155). "0x357147caf9C0cCa67DfA0CF5369318d8193c8407", // SignMessageLib `v1.3.0` (zksync). - "0xd53cd0aB83D845Ac265BE939c57F53AD838012c9" // SignMessageLib `v1.4.1` (canonical). + "0xd53cd0aB83D845Ac265BE939c57F53AD838012c9", // SignMessageLib `v1.4.1` (canonical). + "0x4FfeF8222648872B3dE295Ba1e49110E61f5b5aa", // SignMessageLib `v1.5.0` (canonical). ]; \ No newline at end of file diff --git a/app/components/transaction/ApiInputFields.tsx b/app/components/transaction/ApiInputFields.tsx index 851a6bb..f78a79f 100644 --- a/app/components/transaction/ApiInputFields.tsx +++ b/app/components/transaction/ApiInputFields.tsx @@ -27,6 +27,7 @@ interface ApiInputFieldsProps { export default function ApiInputFields({ form }: ApiInputFieldsProps) { const [activeTooltip, setActiveTooltip] = useState(null); const nestedSafeEnabled = form.watch("nestedSafeEnabled"); + const apiNetworks = NETWORKS.filter((network) => !!network.apiUrl); const handleTooltipToggle = (id: string) => { setActiveTooltip(activeTooltip === id ? null : id); @@ -43,7 +44,7 @@ export default function ApiInputFields({ form }: ApiInputFieldsProps) {