Skip to content

Commit 8dd1c00

Browse files
katspaughclaude
andauthored
feat: Add EIP-3770 chain-prefixed address support with validation (#15)
* feat: Add EIP-3770 chain-prefixed address support with validation Add comprehensive support for EIP-3770 chain-prefixed addresses across all address input fields. Users can now use either plain addresses (0x...) or chain-prefixed addresses (eth:0x..., matic:0x..., etc.). When a chain prefix is provided, the system validates that it matches the current Safe's chain and errors out if there's a mismatch. Changes: - Add validateAddressWithChain() and assertAddressWithChain() methods to ValidationService for EIP-3770 support and chain validation - Update transaction creation 'To' field to support EIP-3770 format - Update TransactionBuilder to validate address-type parameters with chain prefix support - Update account management commands (open, create, add-owner) to accept chain-prefixed addresses - Add 17 comprehensive tests for EIP-3770 validation - Maintain backward compatibility: plain addresses work as before All address inputs now show helpful placeholders like "0x... or eth:0x..." and provide clear error messages on chain mismatch (e.g., "Chain mismatch: address is for Polygon (matic:) but current Safe is on Ethereum"). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Wrap assertAddressWithChain in try-catch in validation functions Validation functions should only return error strings, not throw errors. The assertAddressWithChain call was duplicated - once inside the validate function and once after the prompt. This fix wraps the assertion in a try-catch block inside the validate function to properly handle errors as return values. Changes: - Wrap assertAddressWithChain in try-catch within validation functions - Add comments to clarify the duplicate check logic - Keep single assertion after prompt for final processing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: Use ValidationService for address validation in TransactionBuilder Replace duplicated EIP-3770 validation logic in TransactionBuilder with calls to ValidationService methods. This eliminates code duplication and ensures consistent validation behavior across the codebase. Changes: - Add ValidationService instance to TransactionBuilder - Replace parseParameter address logic with assertAddressWithChain() - Use validateAddressWithChain() directly in validateParameter() - Remove unused Address import - Add comments explaining the refactoring Benefits: - Single source of truth for address validation - Reduced maintenance burden - Consistent error messages - Better code organization 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b53dcf4 commit 8dd1c00

File tree

9 files changed

+327
-44
lines changed

9 files changed

+327
-44
lines changed

src/commands/account/add-owner.ts

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import * as p from '@clack/prompts'
22
import pc from 'picocolors'
3-
import { isAddress, type Address } from 'viem'
3+
import { type Address } from 'viem'
44
import { getConfigStore } from '../../storage/config-store.js'
55
import { getSafeStorage } from '../../storage/safe-store.js'
66
import { getWalletStorage } from '../../storage/wallet-store.js'
77
import { getTransactionStore } from '../../storage/transaction-store.js'
88
import { TransactionService } from '../../services/transaction-service.js'
9+
import { getValidationService } from '../../services/validation-service.js'
910
import { SafeCLIError } from '../../utils/errors.js'
1011
import { parseSafeAddress, formatSafeAddress } from '../../utils/eip3770.js'
11-
import { validateAndChecksumAddress } from '../../utils/validation.js'
1212
import { renderScreen } from '../../ui/render.js'
1313
import { OwnerAddSuccessScreen } from '../../ui/screens/index.js'
1414

@@ -125,15 +125,30 @@ export async function addOwner(account?: string) {
125125
}
126126

127127
// Get new owner address
128+
const validator = getValidationService()
128129
const newOwnerInput = await p.text({
129-
message: 'New owner address:',
130-
placeholder: '0x...',
130+
message: 'New owner address (supports EIP-3770 format: shortName:address):',
131+
placeholder: '0x... or eth:0x...',
131132
validate: (value) => {
132-
if (!value) return 'Address is required'
133-
if (!isAddress(value)) return 'Invalid Ethereum address'
134-
if (currentOwners.some((o) => o.toLowerCase() === value.toLowerCase())) {
135-
return 'Address is already an owner'
133+
const addressError = validator.validateAddressWithChain(value, chainId, chains)
134+
if (addressError) return addressError
135+
136+
// Check for duplicates - need to get checksummed version
137+
try {
138+
const checksummed = validator.assertAddressWithChain(
139+
value as string,
140+
chainId,
141+
chains,
142+
'Owner address'
143+
)
144+
if (currentOwners.some((o) => o.toLowerCase() === checksummed.toLowerCase())) {
145+
return 'Address is already an owner'
146+
}
147+
} catch (error) {
148+
// Should not happen since validateAddressWithChain already passed
149+
return error instanceof Error ? error.message : 'Invalid address'
136150
}
151+
137152
return undefined
138153
},
139154
})
@@ -143,10 +158,15 @@ export async function addOwner(account?: string) {
143158
return
144159
}
145160

146-
// Checksum the address immediately
161+
// Checksum the address (strips EIP-3770 prefix if present)
147162
let newOwner: Address
148163
try {
149-
newOwner = validateAndChecksumAddress(newOwnerInput as string)
164+
newOwner = validator.assertAddressWithChain(
165+
newOwnerInput as string,
166+
chainId,
167+
chains,
168+
'Owner address'
169+
)
150170
} catch (error) {
151171
p.log.error(error instanceof Error ? error.message : 'Invalid address')
152172
p.outro('Failed')

src/commands/account/create.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export async function createSafe() {
4646
}
4747

4848
const chain = configStore.getChain(chainId as string)!
49+
const chainsConfig = configStore.getAllChains()
4950

5051
// Configure owners
5152
const owners: Address[] = []
@@ -86,13 +87,30 @@ export async function createSafe() {
8687
}
8788

8889
const ownerAddress = await p.text({
89-
message: 'Owner address:',
90-
placeholder: '0x...',
90+
message: 'Owner address (supports EIP-3770 format: shortName:address):',
91+
placeholder: '0x... or eth:0x...',
9192
validate: (value) => {
92-
const addressError = validator.validateAddress(value)
93+
const addressError = validator.validateAddressWithChain(
94+
value,
95+
chainId as string,
96+
chainsConfig
97+
)
9398
if (addressError) return addressError
94-
const checksummed = checksumAddress(value as string)
95-
if (owners.includes(checksummed as Address)) return 'Owner already added'
99+
100+
// Check for duplicates - need to get checksummed version
101+
try {
102+
const checksummed = validator.assertAddressWithChain(
103+
value as string,
104+
chainId as string,
105+
chainsConfig,
106+
'Owner address'
107+
)
108+
if (owners.includes(checksummed)) return 'Owner already added'
109+
} catch (error) {
110+
// Should not happen since validateAddressWithChain already passed
111+
return error instanceof Error ? error.message : 'Invalid address'
112+
}
113+
96114
return undefined
97115
},
98116
})
@@ -102,7 +120,12 @@ export async function createSafe() {
102120
return
103121
}
104122

105-
const checksummed = checksumAddress(ownerAddress as string)
123+
const checksummed = validator.assertAddressWithChain(
124+
ownerAddress as string,
125+
chainId as string,
126+
chainsConfig,
127+
'Owner address'
128+
)
106129
owners.push(checksummed)
107130
console.log(`✓ Added ${shortenAddress(checksummed)}`)
108131
}

src/commands/account/open.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { type Address, isAddress } from 'viem'
44
import { getConfigStore } from '../../storage/config-store.js'
55
import { getSafeStorage } from '../../storage/safe-store.js'
66
import { SafeService } from '../../services/safe-service.js'
7-
import { isValidAddress } from '../../utils/validation.js'
7+
import { getValidationService } from '../../services/validation-service.js'
88
import { checksumAddress, shortenAddress } from '../../utils/ethereum.js'
99
import { logError } from '../../ui/messages.js'
1010
import { renderScreen } from '../../ui/render.js'
@@ -82,22 +82,25 @@ export async function openSafe(addressArg?: string) {
8282
chainId = selectedChainId as string
8383

8484
// Get Safe address
85+
const validator = getValidationService()
8586
const address = await p.text({
86-
message: 'Safe address:',
87-
placeholder: '0x...',
88-
validate: (value) => {
89-
if (!value) return 'Address is required'
90-
if (!isValidAddress(value)) return 'Invalid Ethereum address'
91-
return undefined
92-
},
87+
message: 'Safe address (supports EIP-3770 format: shortName:address):',
88+
placeholder: '0x... or eth:0x...',
89+
validate: (value) => validator.validateAddressWithChain(value, chainId, chains),
9390
})
9491

9592
if (p.isCancel(address)) {
9693
p.cancel('Operation cancelled')
9794
return
9895
}
9996

100-
safeAddress = checksumAddress(address as string) as Address
97+
// Strip EIP-3770 prefix if present
98+
safeAddress = validator.assertAddressWithChain(
99+
address as string,
100+
chainId,
101+
chains,
102+
'Safe address'
103+
)
101104
}
102105

103106
const chain = configStore.getChain(chainId)!

src/commands/tx/create.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,18 +104,18 @@ export async function createTransaction() {
104104

105105
// Get transaction details
106106
const toInput = await p.text({
107-
message: 'To address',
108-
placeholder: '0x...',
109-
validate: (value) => validator.validateAddress(value),
107+
message: 'To address (supports EIP-3770 format: shortName:address)',
108+
placeholder: '0x... or eth:0x...',
109+
validate: (value) => validator.validateAddressWithChain(value, chainId, chains),
110110
})
111111

112112
if (p.isCancel(toInput)) {
113113
p.cancel('Operation cancelled')
114114
return
115115
}
116116

117-
// Checksum the address
118-
const to = validator.assertAddress(toInput as string, 'To address')
117+
// Checksum the address (strips EIP-3770 prefix if present)
118+
const to = validator.assertAddressWithChain(toInput as string, chainId, chains, 'To address')
119119

120120
// Check if address is a contract
121121
const contractService = new ContractService(chain)
@@ -275,7 +275,7 @@ export async function createTransaction() {
275275
}
276276

277277
// Build transaction using interactive builder
278-
const builder = new TransactionBuilder(abi)
278+
const builder = new TransactionBuilder(abi, chainId, chains)
279279
const result = await builder.buildFunctionCall(func)
280280

281281
value = result.value

src/services/transaction-builder.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import * as p from '@clack/prompts'
2-
import { encodeFunctionData, parseEther, type Address } from 'viem'
2+
import { encodeFunctionData, parseEther } from 'viem'
33
import type { ABIFunction, ABI } from './abi-service.js'
4+
import type { ChainConfig } from '../types/config.js'
5+
import { getValidationService } from './validation-service.js'
46
import { SafeCLIError } from '../utils/errors.js'
57

68
export interface TransactionBuilderResult {
@@ -13,9 +15,14 @@ export interface TransactionBuilderResult {
1315
*/
1416
export class TransactionBuilder {
1517
private abi: ABI
18+
private chainId: string
19+
private chains: Record<string, ChainConfig>
20+
private validator = getValidationService()
1621

17-
constructor(abi: ABI) {
22+
constructor(abi: ABI, chainId: string, chains: Record<string, ChainConfig>) {
1823
this.abi = abi
24+
this.chainId = chainId
25+
this.chains = chains
1926
}
2027

2128
/**
@@ -102,7 +109,7 @@ export class TransactionBuilder {
102109
* Get placeholder text for parameter type
103110
*/
104111
private getPlaceholder(type: string): string {
105-
if (type === 'address') return '0x...'
112+
if (type === 'address') return '0x... or eth:0x...'
106113
if (type.startsWith('uint') || type.startsWith('int')) return '123'
107114
if (type === 'bool') return 'true or false'
108115
if (type === 'string') return 'your text here'
@@ -115,6 +122,12 @@ export class TransactionBuilder {
115122
* Validate parameter input
116123
*/
117124
private validateParameter(value: string, type: string): string | undefined {
125+
// For addresses, use ValidationService directly to avoid try-catch overhead
126+
if (type === 'address') {
127+
return this.validator.validateAddressWithChain(value, this.chainId, this.chains)
128+
}
129+
130+
// For other types, parse and catch errors
118131
try {
119132
this.parseParameter(value, type)
120133
return undefined
@@ -127,12 +140,10 @@ export class TransactionBuilder {
127140
* Parse parameter value based on type
128141
*/
129142
private parseParameter(value: string, type: string): unknown {
130-
// Address
143+
// Address (with EIP-3770 support using ValidationService)
131144
if (type === 'address') {
132-
if (!/^0x[a-fA-F0-9]{40}$/.test(value)) {
133-
throw new Error('Invalid address format')
134-
}
135-
return value as Address
145+
// Use ValidationService for consistent address validation and chain checking
146+
return this.validator.assertAddressWithChain(value, this.chainId, this.chains, 'Parameter')
136147
}
137148

138149
// Boolean

src/services/validation-service.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { isAddress, isHex, getAddress, type Address } from 'viem'
22
import { ValidationError } from '../utils/errors.js'
3+
import { isEIP3770, parseEIP3770, getChainIdFromShortName } from '../utils/eip3770.js'
4+
import type { ChainConfig } from '../types/config.js'
35

46
/**
57
* Centralized validation service for all input validation across the CLI.
@@ -23,6 +25,61 @@ export class ValidationService {
2325
return undefined
2426
}
2527

28+
/**
29+
* Validates an Ethereum address with EIP-3770 support and chain verification
30+
* Accepts both plain addresses (0x...) and EIP-3770 format (shortName:0x...)
31+
* If EIP-3770 format is provided, validates that the chain prefix matches expectedChainId
32+
*
33+
* @param value - Address to validate (plain or EIP-3770 format)
34+
* @param expectedChainId - The chain ID that the address should be for
35+
* @param chains - Chain configurations to resolve shortNames
36+
* @returns Error message or undefined if valid
37+
*/
38+
validateAddressWithChain(
39+
value: unknown,
40+
expectedChainId: string,
41+
chains: Record<string, ChainConfig>
42+
): string | undefined {
43+
if (!value || typeof value !== 'string') {
44+
return 'Address is required'
45+
}
46+
47+
// Check if it's EIP-3770 format
48+
if (isEIP3770(value)) {
49+
try {
50+
const { shortName, address } = parseEIP3770(value)
51+
52+
// Resolve the chainId from the shortName
53+
const chainId = getChainIdFromShortName(shortName, chains)
54+
55+
// Check if it matches the expected chain
56+
if (chainId !== expectedChainId) {
57+
const expectedChain = chains[expectedChainId]
58+
const providedChain = chains[chainId]
59+
const expectedName = expectedChain?.name || expectedChainId
60+
const providedName = providedChain?.name || chainId
61+
62+
return `Chain mismatch: address is for ${providedName} (${shortName}:) but current Safe is on ${expectedName}`
63+
}
64+
65+
// Validate the address part
66+
if (!isAddress(address)) {
67+
return 'Invalid Ethereum address'
68+
}
69+
70+
return undefined
71+
} catch (error) {
72+
if (error instanceof Error) {
73+
return error.message
74+
}
75+
return 'Invalid EIP-3770 address format'
76+
}
77+
}
78+
79+
// Plain address format - validate normally
80+
return this.validateAddress(value)
81+
}
82+
2683
/**
2784
* Asserts an Ethereum address is valid and returns checksummed version
2885
* @throws ValidationError if invalid
@@ -41,6 +98,46 @@ export class ValidationService {
4198
}
4299
}
43100

101+
/**
102+
* Asserts an Ethereum address is valid (with EIP-3770 support) and returns checksummed version
103+
* Strips the EIP-3770 prefix if present and validates chain match
104+
*
105+
* @param value - Address to validate (plain or EIP-3770 format)
106+
* @param expectedChainId - The chain ID that the address should be for
107+
* @param chains - Chain configurations to resolve shortNames
108+
* @param fieldName - Field name for error messages
109+
* @returns Checksummed address (without EIP-3770 prefix)
110+
* @throws ValidationError if invalid
111+
*/
112+
assertAddressWithChain(
113+
value: string,
114+
expectedChainId: string,
115+
chains: Record<string, ChainConfig>,
116+
fieldName = 'Address'
117+
): Address {
118+
const error = this.validateAddressWithChain(value, expectedChainId, chains)
119+
if (error) {
120+
throw new ValidationError(`${fieldName}: ${error}`)
121+
}
122+
123+
// If EIP-3770 format, extract the address part
124+
let address: string
125+
if (isEIP3770(value)) {
126+
const parsed = parseEIP3770(value)
127+
address = parsed.address
128+
} else {
129+
address = value
130+
}
131+
132+
try {
133+
return getAddress(address)
134+
} catch (error) {
135+
throw new ValidationError(
136+
`${fieldName}: Invalid address checksum - ${error instanceof Error ? error.message : 'Unknown error'}`
137+
)
138+
}
139+
}
140+
44141
/**
45142
* Validates a private key (64 hex characters with optional 0x prefix)
46143
* @returns Error message or undefined if valid

src/tests/integration/integration-transaction-builder.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ describe('E2E Transaction Builder Test', () => {
191191
// 5. Build Approval Transaction
192192
// ============================================
193193
console.log('\n[E2E] Step 5: Build ERC20 approval transaction')
194-
const transactionBuilder = new TransactionBuilder(abi)
194+
const transactionBuilder = new TransactionBuilder(abi, SEPOLIA_CHAIN_ID, DEFAULT_CHAINS)
195195

196196
// Build the transaction data for approving 100 DAI to the Safe itself
197197
// (This is safe since we control the Safe)

0 commit comments

Comments
 (0)