From 71249155da6ba917eee3bb4679f10318c847efd0 Mon Sep 17 00:00:00 2001 From: netbonus <151201453+netbonus@users.noreply.github.com> Date: Tue, 2 Sep 2025 16:28:58 -0400 Subject: [PATCH] Adds signing for eip712 txs --- README.md | 108 +++++++++++++++++ src/commands/index.ts | 3 +- src/commands/signEIP712.ts | 185 +++++++++++++++++++++++++++++ src/constants.ts | 1 + src/prompts.ts | 7 +- src/utils/eip712Formatter.ts | 211 +++++++++++++++++++++++++++++++++ src/utils/eip712Validator.ts | 218 +++++++++++++++++++++++++++++++++++ 7 files changed, 731 insertions(+), 2 deletions(-) create mode 100644 src/commands/signEIP712.ts create mode 100644 src/utils/eip712Formatter.ts create mode 100644 src/utils/eip712Validator.ts diff --git a/README.md b/README.md index 606a400..52dfd14 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ This will generate a set of binaries in `./releases`. Run the one you wish to us * [Change Withdrawal Credentials](#change-withdrawal-credentials) * [Get Address](#get-address) * [Get Public Key](#get-public-key) + * [Sign EIP-712 Data](#sign-eip-712-data) # šŸ”— Connecting to a Lattice @@ -182,3 +183,110 @@ The Lattice is able to export a few types of *formatted* addresses, which depend | `84'` | `0'` | Bitcoin bech32 | `bc1...` | | `49'` | `0'` | Bitcoin wrapped segwit | `3...` | | `44'` | `0'` | Bitcoin legacy | `1...` | + + +## šŸ” Sign EIP-712 Data + +The Lattice can sign [EIP-712](https://eips.ethereum.org/EIPS/eip-712) typed structured data, which is commonly used by DeFi protocols, DAOs, and modern dApps for operations like token permits, governance voting, and order signing. + +### What is EIP-712? + +EIP-712 is a standard for hashing and signing typed structured data. Unlike regular message signing, EIP-712 provides: +- **Type safety**: Data is strongly typed with a defined schema +- **Human readability**: Users can see exactly what they're signing +- **Domain separation**: Prevents signatures from being replayed across different applications + +### Using the Sign EIP-712 Command + +When you select the `Sign EIP-712 Data` command, you'll be prompted to provide the typed data in one of two ways: + +1. **From a JSON file**: Provide the path to a JSON file containing the EIP-712 data +2. **Direct input**: Paste the JSON directly into the CLI + +### EIP-712 Data Format + +The JSON must contain the following fields: + +```json +{ + "types": { + "EIP712Domain": [...], + "YourMessageType": [...] + }, + "primaryType": "YourMessageType", + "domain": { + "name": "Application Name", + "version": "1", + "chainId": 1, + "verifyingContract": "0x..." + }, + "message": { + // Your actual message data + } +} +``` + +### Example: Token Permit + +A common use case is EIP-2612 token permits. See `sample-eip712.json` for a complete example: + +```json +{ + "types": { + "EIP712Domain": [ + { "name": "name", "type": "string" }, + { "name": "version", "type": "string" }, + { "name": "chainId", "type": "uint256" }, + { "name": "verifyingContract", "type": "address" } + ], + "Permit": [ + { "name": "owner", "type": "address" }, + { "name": "spender", "type": "address" }, + { "name": "value", "type": "uint256" }, + { "name": "nonce", "type": "uint256" }, + { "name": "deadline", "type": "uint256" } + ] + }, + "primaryType": "Permit", + "domain": { + "name": "MyToken", + "version": "1", + "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + }, + "message": { + "owner": "0x1234567890123456789012345678901234567890", + "spender": "0x2345678901234567890123456789012345678901", + "value": 1000000000000000000, + "nonce": 0, + "deadline": 1234567890 + } +} +``` + +### Signing Process + +1. The CLI will validate your EIP-712 data structure +2. You'll see a formatted preview of what you're about to sign +3. After confirmation, the request is sent to your Lattice +4. Confirm the signature on your Lattice device +5. The signature is returned in multiple formats: + - **Full signature**: Complete hex string (65 bytes) + - **Components**: Separate v, r, s values for use in smart contracts + +### Saving Signatures + +After signing, you can optionally save the signature to a JSON file that includes: +- The complete signature +- Individual v, r, s components +- The original typed data +- A timestamp + +This is useful for record-keeping or for later submission to smart contracts or dApps. + +### Security Notes + +- Always verify the domain and message contents before signing +- The Lattice will display key information about what you're signing +- Never sign data from untrusted sources +- Signatures are binding and cannot be revoked once used on-chain diff --git a/src/commands/index.ts b/src/commands/index.ts index 92d83bc..45d028c 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,4 +1,5 @@ export * from './buildDepositData'; export * from './changeBLSCredentials'; export * from './getAddress'; -export * from './getPubkey'; \ No newline at end of file +export * from './getPubkey'; +export * from './signEIP712'; \ No newline at end of file diff --git a/src/commands/signEIP712.ts b/src/commands/signEIP712.ts new file mode 100644 index 0000000..2599fff --- /dev/null +++ b/src/commands/signEIP712.ts @@ -0,0 +1,185 @@ +import { Client } from "gridplus-sdk"; +import { + clearPrintedLines, + closeSpinner, + printColor, + startNewSpinner, + pathStrToInt, +} from '../utils'; +import { + promptForSelect, + promptForString, + promptForBool, + promptGetPath +} from '../prompts'; +import { validateEIP712Data } from '../utils/eip712Validator'; +import { formatEIP712ForDisplay, formatSignature } from '../utils/eip712Formatter'; +import { readFileSync, writeFileSync } from 'fs'; + +/** + * Sign EIP-712 typed data using the Lattice hardware wallet. + * Supports multiple input methods and output formats. + */ +export async function cmdSignEIP712(client: Client) { + try { + // Get input method + const inputMethod = await promptForSelect( + "How would you like to provide the EIP-712 data?", + ["From file (JSON)", "Direct input (JSON string)", "Cancel"] + ); + + if (inputMethod === "Cancel") { + return; + } + + let typedDataStr: string; + + // Get the typed data based on input method + if (inputMethod === "From file (JSON)") { + const filePath = await promptForString("Enter path to JSON file: "); + try { + typedDataStr = readFileSync(filePath, 'utf-8'); + } catch (err) { + printColor(`Failed to read file: ${err}`, "red"); + return; + } + } else { + typedDataStr = await promptForString( + "Enter EIP-712 JSON (or paste and press Enter): " + ); + } + + // Parse and validate the typed data + let typedData: any; + try { + typedData = JSON.parse(typedDataStr); + } catch (err) { + printColor("Invalid JSON format", "red"); + return; + } + + // Validate EIP-712 structure + const validationResult = validateEIP712Data(typedData); + if (!validationResult.isValid) { + printColor(`Invalid EIP-712 structure: ${validationResult.error}`, "red"); + return; + } + + // Display formatted preview + console.log("\nšŸ“‹ EIP-712 Data Preview:"); + console.log("========================"); + const preview = formatEIP712ForDisplay(typedData); + console.log(preview); + console.log("========================\n"); + + // Confirm before signing + const shouldSign = await promptForBool( + "Do you want to sign this data? " + ); + + if (!shouldSign) { + printColor("Signing cancelled", "yellow"); + return; + } + + // Ask for derivation path (optional, use default ETH path) + const useDefaultPath = await promptForBool( + "Use default Ethereum signing path (m/44'/60'/0'/0/0)? " + ); + + let signerPath: number[]; + if (useDefaultPath) { + signerPath = pathStrToInt("m/44'/60'/0'/0/0"); + } else { + const pathStr = await promptGetPath("m/44'/60'/0'/0/0"); + signerPath = pathStrToInt(pathStr); + } + + // Prepare the signing request + const spinner = startNewSpinner("Requesting signature from Lattice..."); + let spinnerClosed = false; + + try { + // Sign the typed data using GridPlus SDK format + // According to the SDK docs, EIP-712 uses protocol: 'eip712' with ETH_MSG currency + const signatureResult = await client.sign({ + currency: 'ETH_MSG', + data: { + protocol: 'eip712', + payload: typedData, + signerPath: signerPath + } + } as any); + + closeSpinner(spinner, "Signature received from Lattice"); + spinnerClosed = true; + + // Format the signature - GridPlus SDK returns {sig: {v, r, s}, signer: Buffer} + const formattedSig = formatSignature(signatureResult.sig); + + // Display signature + console.log("\nāœ… Signature Generated:"); + console.log("========================"); + console.log(`Full signature: ${formattedSig.full}`); + console.log(`\nComponents:`); + console.log(` v: ${formattedSig.v}`); + console.log(` r: ${formattedSig.r}`); + console.log(` s: ${formattedSig.s}`); + + // Show signer address if available + if (signatureResult.signer) { + const signerAddress = '0x' + signatureResult.signer.toString('hex'); + console.log(`\nSigner address: ${signerAddress}`); + } + console.log("========================\n"); + + // Ask if user wants to save + const shouldSave = await promptForBool( + "Save signature to file? " + ); + + if (shouldSave) { + const outputPath = await promptForString( + "Enter output file path: ", + "signature.json" + ); + + const output = { + signature: formattedSig.full, + components: { + v: formattedSig.v, + r: formattedSig.r, + s: formattedSig.s + }, + signer: signatureResult.signer ? '0x' + signatureResult.signer.toString('hex') : undefined, + typedData: typedData, + timestamp: new Date().toISOString() + }; + + try { + writeFileSync(outputPath, JSON.stringify(output, null, 2)); + printColor(`Signature saved to ${outputPath}`, "green"); + } catch (err) { + printColor(`Failed to save signature: ${err}`, "red"); + } + } + + } catch (err) { + if (!spinnerClosed) { + closeSpinner( + spinner, + `Failed to sign data: ${err instanceof Error ? err.message : 'Unknown error'}`, + false + ); + } else { + printColor(`Error after signing: ${err instanceof Error ? err.message : 'Unknown error'}`, "red"); + } + } + + } catch (err) { + printColor( + `Error in EIP-712 signing: ${err instanceof Error ? err.message : 'Unknown error'}`, + "red" + ); + } +} \ No newline at end of file diff --git a/src/constants.ts b/src/constants.ts index e0d8ad2..6c3e079 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,6 +4,7 @@ const COMMANDS = { EXPORT_DEPOSIT_DATA: 'Export Validator Deposit Data', GET_ADDRESS: 'Get Address', GET_PUBLIC_KEY: 'Get Public Key', + SIGN_EIP712: 'Sign EIP-712 Data', }; const DEFAULT_PATHS = { diff --git a/src/prompts.ts b/src/prompts.ts index 6dc8686..054bf60 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -6,7 +6,8 @@ import { cmdChangeBLSCredentials, cmdGenDepositData, cmdGetAddresses, - cmdGetPubkeys + cmdGetPubkeys, + cmdSignEIP712 } from "./commands"; import { clearPrintedLines } from "./utils"; @@ -64,6 +65,7 @@ export const promptForCommand = async (client: Client) => { COMMANDS.CHANGE_BLS_WITHDRAWAL_CREDS, COMMANDS.GET_ADDRESS, COMMANDS.GET_PUBLIC_KEY, + COMMANDS.SIGN_EIP712, COMMANDS.EXIT, ], }); @@ -82,6 +84,9 @@ export const promptForCommand = async (client: Client) => { case COMMANDS.CHANGE_BLS_WITHDRAWAL_CREDS: await cmdChangeBLSCredentials(client); break; + case COMMANDS.SIGN_EIP712: + await cmdSignEIP712(client); + break; case COMMANDS.EXIT: process.exit(0); break; diff --git a/src/utils/eip712Formatter.ts b/src/utils/eip712Formatter.ts new file mode 100644 index 0000000..a619be1 --- /dev/null +++ b/src/utils/eip712Formatter.ts @@ -0,0 +1,211 @@ +/** + * EIP-712 data formatting utilities + */ + +interface FormattedSignature { + full: string; + v: string; + r: string; + s: string; +} + +/** + * Format EIP-712 typed data for human-readable display + */ +export function formatEIP712ForDisplay(typedData: any): string { + const lines: string[] = []; + + // Format domain + lines.push('🌐 Domain:'); + if (typedData.domain) { + for (const [key, value] of Object.entries(typedData.domain)) { + lines.push(` ${key}: ${formatValue(value)}`); + } + } + lines.push(''); + + // Format primary type + lines.push(`šŸ“ Primary Type: ${typedData.primaryType}`); + lines.push(''); + + // Format message + lines.push('šŸ“§ Message:'); + if (typedData.message) { + lines.push(formatObject(typedData.message, ' ')); + } + lines.push(''); + + // Show type definitions summary + if (typedData.types) { + const typeCount = Object.keys(typedData.types).length; + lines.push(`šŸ“‹ Type Definitions: ${typeCount} types defined`); + + // List custom types (excluding EIP712Domain) + const customTypes = Object.keys(typedData.types).filter(t => t !== 'EIP712Domain'); + if (customTypes.length > 0) { + lines.push(` Custom types: ${customTypes.join(', ')}`); + } + } + + return lines.join('\n'); +} + +/** + * Format a value for display + */ +function formatValue(value: any): string { + if (value === null || value === undefined) { + return 'null'; + } + + if (typeof value === 'string') { + // Check if it looks like an address + if (value.startsWith('0x') && value.length === 42) { + return value; + } + // Check if it's a hex string + if (value.startsWith('0x')) { + return value.length > 66 ? `${value.substring(0, 66)}...` : value; + } + return value; + } + + if (typeof value === 'number' || typeof value === 'bigint') { + return value.toString(); + } + + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return '[]'; + } + if (value.length <= 3) { + return `[${value.map(v => formatValue(v)).join(', ')}]`; + } + return `[${formatValue(value[0])}, ... (${value.length} items)]`; + } + + if (typeof value === 'object') { + return '{...}'; + } + + return String(value); +} + +/** + * Format an object for display with indentation + */ +function formatObject(obj: any, indent: string = ''): string { + const lines: string[] = []; + + for (const [key, value] of Object.entries(obj)) { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + lines.push(`${indent}${key}:`); + lines.push(formatObject(value, indent + ' ')); + } else { + lines.push(`${indent}${key}: ${formatValue(value)}`); + } + } + + return lines.join('\n'); +} + +/** + * Format signature from raw bytes to standard format + */ +export function formatSignature(signature: string | Buffer | any): FormattedSignature { + let sigHex: string; + + // Handle GridPlus SDK signature format + if (signature && typeof signature === 'object' && !Buffer.isBuffer(signature)) { + if (signature.v !== undefined && signature.r && signature.s) { + // Extract v value - it may be a Buffer or number + let vValue: number; + if (Buffer.isBuffer(signature.v)) { + vValue = signature.v[0]; + } else if (typeof signature.v === 'number') { + vValue = signature.v; + } else { + vValue = parseInt(signature.v); + } + + // Format r and s (they come as hex strings without 0x) + const r = signature.r.startsWith('0x') ? signature.r.slice(2) : signature.r; + const s = signature.s.startsWith('0x') ? signature.s.slice(2) : signature.s; + + // Ensure proper padding + const rPadded = r.padStart(64, '0'); + const sPadded = s.padStart(64, '0'); + const vHex = vValue.toString(16).padStart(2, '0'); + + return { + full: '0x' + rPadded + sPadded + vHex, + v: '0x' + vHex, + r: '0x' + rPadded, + s: '0x' + sPadded + }; + } + } + + // Convert to hex string if Buffer + if (Buffer.isBuffer(signature)) { + sigHex = '0x' + signature.toString('hex'); + } else if (typeof signature === 'string') { + // Ensure it has 0x prefix + sigHex = signature.startsWith('0x') ? signature : '0x' + signature; + } else { + throw new Error('Invalid signature format'); + } + + // Remove 0x prefix for processing + const sig = sigHex.slice(2); + + // EIP-712 signatures are 65 bytes (130 hex chars) + // r: 32 bytes, s: 32 bytes, v: 1 byte + if (sig.length !== 130) { + throw new Error(`Invalid signature length: expected 130 hex chars, got ${sig.length}`); + } + + const r = '0x' + sig.slice(0, 64); + const s = '0x' + sig.slice(64, 128); + const vHex = sig.slice(128, 130); + const vNum = parseInt(vHex, 16); + + // Format v according to EIP-155 + // Traditional v values are 27 or 28 + // EIP-155 v values encode the chain ID + let v: string; + if (vNum === 0 || vNum === 1) { + // Modern format (0 or 1) + v = '0x' + (vNum + 27).toString(16); + } else { + // Already in correct format + v = '0x' + vNum.toString(16); + } + + return { + full: sigHex, + v, + r, + s + }; +} + +/** + * Encode EIP-712 data for transmission to Lattice + * This may need adjustment based on gridplus-sdk requirements + */ +export function encodeForLattice(typedData: any): any { + // The Lattice/gridplus-sdk may expect a specific format + // This is a placeholder that returns the data as-is + // Actual implementation depends on SDK requirements + return { + types: typedData.types, + primaryType: typedData.primaryType, + domain: typedData.domain, + message: typedData.message + }; +} \ No newline at end of file diff --git a/src/utils/eip712Validator.ts b/src/utils/eip712Validator.ts new file mode 100644 index 0000000..4059ab9 --- /dev/null +++ b/src/utils/eip712Validator.ts @@ -0,0 +1,218 @@ +/** + * EIP-712 typed data validation utilities + */ + +interface ValidationResult { + isValid: boolean; + error?: string; +} + +interface EIP712TypedData { + types: Record>; + primaryType: string; + domain: Record; + message: Record; +} + +/** + * Validate EIP-712 typed data structure + */ +export function validateEIP712Data(data: any): ValidationResult { + // Check for required top-level fields + if (!data || typeof data !== 'object') { + return { isValid: false, error: 'Data must be an object' }; + } + + if (!data.types) { + return { isValid: false, error: 'Missing required field: types' }; + } + + if (!data.primaryType) { + return { isValid: false, error: 'Missing required field: primaryType' }; + } + + if (!data.domain) { + return { isValid: false, error: 'Missing required field: domain' }; + } + + if (!data.message) { + return { isValid: false, error: 'Missing required field: message' }; + } + + // Validate types structure + if (typeof data.types !== 'object' || Array.isArray(data.types)) { + return { isValid: false, error: 'Types must be an object' }; + } + + // Check that EIP712Domain type exists + if (!data.types.EIP712Domain) { + return { isValid: false, error: 'Missing EIP712Domain type definition' }; + } + + // Validate each type definition + for (const [typeName, typeFields] of Object.entries(data.types)) { + if (!Array.isArray(typeFields)) { + return { + isValid: false, + error: `Type ${typeName} must be an array of field definitions` + }; + } + + for (const field of typeFields) { + if (!field.name || !field.type) { + return { + isValid: false, + error: `Invalid field in type ${typeName}: must have name and type` + }; + } + + if (typeof field.name !== 'string' || typeof field.type !== 'string') { + return { + isValid: false, + error: `Invalid field in type ${typeName}: name and type must be strings` + }; + } + } + } + + // Validate that primaryType exists in types + if (!data.types[data.primaryType]) { + return { + isValid: false, + error: `Primary type '${data.primaryType}' not found in type definitions` + }; + } + + // Validate domain structure + const domainType = data.types.EIP712Domain; + for (const field of domainType) { + const fieldName = field.name; + if (data.domain[fieldName] !== undefined) { + // Basic type checking for common domain fields + if (fieldName === 'chainId' && typeof data.domain[fieldName] !== 'number') { + return { + isValid: false, + error: `Domain field 'chainId' must be a number` + }; + } + if (fieldName === 'verifyingContract' && + typeof data.domain[fieldName] === 'string' && + !isValidAddress(data.domain[fieldName])) { + return { + isValid: false, + error: `Domain field 'verifyingContract' must be a valid address` + }; + } + } + } + + // Validate message structure against primaryType + const primaryTypeFields = data.types[data.primaryType]; + for (const field of primaryTypeFields) { + if (data.message[field.name] === undefined) { + return { + isValid: false, + error: `Message missing required field: ${field.name}` + }; + } + } + + // Check for circular references + const circularCheck = checkCircularReferences(data.types); + if (!circularCheck.isValid) { + return circularCheck; + } + + return { isValid: true }; +} + +/** + * Check for circular type references + */ +function checkCircularReferences( + types: Record> +): ValidationResult { + const visited = new Set(); + const recursionStack = new Set(); + + function checkType(typeName: string): boolean { + // Skip primitive types + if (isPrimitiveType(typeName)) { + return true; + } + + // Extract base type from arrays + const baseType = typeName.replace(/\[\d*\]$/, ''); + + if (recursionStack.has(baseType)) { + return false; // Circular reference detected + } + + if (visited.has(baseType)) { + return true; // Already checked, no circular reference + } + + if (!types[baseType]) { + return true; // Type not defined, will be caught elsewhere + } + + visited.add(baseType); + recursionStack.add(baseType); + + for (const field of types[baseType]) { + if (!checkType(field.type)) { + return false; + } + } + + recursionStack.delete(baseType); + return true; + } + + for (const typeName of Object.keys(types)) { + if (typeName !== 'EIP712Domain' && !checkType(typeName)) { + return { + isValid: false, + error: `Circular reference detected involving type: ${typeName}` + }; + } + } + + return { isValid: true }; +} + +/** + * Check if a type is a primitive EIP-712 type + */ +function isPrimitiveType(type: string): boolean { + const primitives = [ + 'bool', + 'string', + 'address', + 'bytes', + /^bytes\d+$/, + /^uint\d*$/, + /^int\d*$/ + ]; + + const baseType = type.replace(/\[\d*\]$/, ''); + + return primitives.some(primitive => { + if (typeof primitive === 'string') { + return baseType === primitive; + } + return primitive.test(baseType); + }); +} + +/** + * Basic Ethereum address validation + */ +function isValidAddress(address: string): boolean { + if (typeof address !== 'string') { + return false; + } + + // Check if it starts with 0x and has 40 hex characters + return /^0x[a-fA-F0-9]{40}$/.test(address); +} \ No newline at end of file