safe-message-tools
Version:
CLI tools for signing and verifying messages with Gnosis Safe. Supports EIP-712, EIP-1271, hardware wallets, and multi-signature coordination.
170 lines (147 loc) • 4.42 kB
JavaScript
import { ethers } from 'ethers'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import yargs from 'yargs'
import { hideBin } from 'yargs/helpers'
import { safeTypedData, safeMessageHash } from '../lib/safe.js'
const cli = yargs(hideBin(process.argv))
.option('safe', {
type: 'string',
required: true,
describe: 'Safe address'
})
.option('msg', {
type: 'string',
required: true,
describe: 'Message file path'
})
.option('rpc', {
type: 'string',
required: true,
describe: 'RPC endpoint URL'
})
.option('sig', {
type: 'string',
describe: 'Signature to add'
})
.option('signer', {
type: 'string',
describe: 'Signer address (required with --sig)'
})
.option('output', {
type: 'string',
default: 'signatures.json',
describe: 'Output file path'
})
.option('verify', {
type: 'boolean',
describe: 'Verify all signatures'
})
.help()
.argv
function loadSignatures(file) {
if (!existsSync(file)) {
return { message: '', hash: '', safe: '', chainId: 0, signatures: [] }
}
try {
return JSON.parse(readFileSync(file, 'utf8'))
} catch {
console.error(`failed to read ${file}`)
process.exit(1)
}
}
function saveSignatures(file, data) {
try {
writeFileSync(file, JSON.stringify(data, null, 2))
} catch (error) {
console.error(`failed to save ${file}: ${error.message}`)
process.exit(1)
}
}
async function getSafeOwners(safe, provider) {
try {
const contract = new ethers.Contract(safe, ['function getOwners() view returns (address[])'], provider)
return await contract.getOwners()
} catch {
return []
}
}
async function main() {
const { safe, msg, rpc, sig, signer, output, verify } = cli
if (!ethers.isAddress(safe)) {
console.error('invalid safe address')
process.exit(1)
}
let message
try {
message = readFileSync(msg, 'utf8').trim()
} catch {
console.error(`failed to read ${msg}`)
process.exit(1)
}
const provider = new ethers.JsonRpcProvider(rpc)
let chainId
try {
const network = await provider.getNetwork()
chainId = Number(network.chainId)
} catch {
console.error('rpc connection failed')
process.exit(1)
}
const messageHash = safeMessageHash(safe, message, chainId)
let data = loadSignatures(output)
// Initialize or validate
if (data.signatures.length === 0) {
data = { message, hash: messageHash, safe, chainId, signatures: [] }
} else if (data.hash !== messageHash || data.safe !== safe || data.chainId !== chainId) {
console.error('signature file mismatch - use different output file')
process.exit(1)
}
// Add signature
if (sig && signer) {
if (!ethers.isAddress(signer)) {
console.error('invalid signer address')
process.exit(1)
}
const existing = data.signatures.find(s => s.signer.toLowerCase() === signer.toLowerCase())
if (existing) {
existing.signature = sig
} else {
data.signatures.push({ signer, signature: sig })
}
saveSignatures(output, data)
console.log(`added signature from ${signer}`)
}
// Verify signatures
if (verify) {
const { domain, types, value } = safeTypedData(safe, message, chainId)
const owners = await getSafeOwners(safe, provider)
let valid = 0
for (const s of data.signatures) {
try {
const recovered = ethers.verifyTypedData(domain, types, value, s.signature)
const isValid = recovered.toLowerCase() === s.signer.toLowerCase()
const isOwner = owners.length === 0 || owners.some(o => o.toLowerCase() === s.signer.toLowerCase())
if (isValid && isOwner) valid++
console.log(`${s.signer}: ${isValid ? 'valid' : 'invalid'} ${isOwner ? '(owner)' : '(not owner)'}`)
} catch {
console.log(`${s.signer}: verification failed`)
}
}
console.log(`${valid}/${data.signatures.length} valid signatures`)
}
// Status
console.log(`Message: ${message}`)
console.log(`Safe: ${safe}`)
console.log(`Hash: ${messageHash}`)
console.log(`Signatures: ${data.signatures.length}`)
if (data.signatures.length > 0) {
data.signatures.forEach((s, i) => {
console.log(` ${i + 1}. ${s.signer}`)
})
}
}
main().catch(err => {
console.error(err.message)
process.exit(1)
})