Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions MASTERCOPY_MATCHING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Mastercopy Code Matching Implementation

## Overview

This implementation adds support for Safe contracts whose mastercopy address is not listed in the official `safe-deployments` package. Instead of refusing to initialize, the SDK now attempts to match the mastercopy's bytecode hash against all known Safe versions.

## Implementation Details

### 1. Mastercopy Matcher Utility (`src/utils/mastercopyMatcher.ts`)

Three main functions are provided:

#### `getMasterCopyAddressFromProxy(safeProvider, safeAddress)`
- Reads the mastercopy address from storage slot 0 of the Safe proxy
- Returns the checksummed mastercopy address

#### `matchContractCodeToSafeVersion(safeProvider, contractAddress, chainId, isL1SafeSingleton)`
- Fetches the bytecode of the contract at the given address
- Computes the keccak256 hash of the bytecode
- Compares against all known Safe versions (1.4.1, 1.3.0, 1.2.0, 1.1.1, 1.0.0)
- Checks both L1 and L2 singleton variants
- Uses the pre-computed `codeHash` from safe-deployments for efficient matching
- Returns the matched version and whether it's an L1 singleton, or undefined if no match

#### `detectSafeVersionFromMastercopy(safeProvider, safeAddress, chainId, isL1SafeSingleton)`
- Combines the above two functions
- Reads the mastercopy address from the Safe proxy
- Matches the mastercopy code to a known Safe version
- Returns version, mastercopy address, and L1 flag, or undefined if detection fails

### 2. Contract Manager Integration (`src/managers/contractManager.ts`)

Modified the `#initializeContractManager` method to use mastercopy matching as a fallback:

```typescript
try {
// Try to fetch the version via VERSION() call
safeVersion = await getSafeContractVersion(safeProvider, safeAddress)
} catch (e) {
// If VERSION() fails, try mastercopy matching
const mastercopyMatch = await detectSafeVersionFromMastercopy(
safeProvider,
safeAddress,
chainId,
isL1SafeSingleton
)

if (mastercopyMatch) {
// Successfully matched - use detected version and L1 flag
safeVersion = mastercopyMatch.version
detectedIsL1 = mastercopyMatch.isL1
} else {
// No match found - fall back to default version
safeVersion = DEFAULT_SAFE_VERSION
}
}
```

The detected `isL1` flag is then passed to `getSafeContract` to ensure the correct singleton type is used.

## Benefits

1. **Backward Compatibility**: Safes deployed with official mastercopies work exactly as before
2. **Extended Support**: Safes using custom-deployed (but code-identical) mastercopies are now supported
3. **Graceful Fallback**: If VERSION() call fails for any reason, the SDK attempts mastercopy matching before falling back to the default version
4. **Accurate Detection**: By comparing bytecode hashes, we ensure the mastercopy is exactly the same as an official version
5. **Automatic L1/L2 Detection**: The implementation correctly identifies whether a Safe uses an L1 or L2 singleton

## Use Cases

This implementation enables the SDK to work with:
- Safes deployed on custom networks or testnets using official Safe bytecode
- Safes where the mastercopy was deployed independently but matches official versions
- Safes where the VERSION() method is inaccessible but the bytecode is correct

## Testing

Unit tests have been added in `tests/unit/mastercopy-matcher.test.ts` covering:
- Extracting mastercopy address from storage
- Matching contract code against known versions
- End-to-end detection workflow
- Error handling and edge cases

## Security Considerations

- The implementation only accepts mastercopies whose bytecode EXACTLY matches official Safe deployments
- No custom or modified mastercopies are accepted unless they have the identical bytecode hash
- The keccak256 hash comparison provides cryptographic assurance of code identity
136 changes: 136 additions & 0 deletions docs/CUSTOM_MASTERCOPY_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Using Safe SDK with Custom Mastercopy Deployments

## Overview

The Safe protocol-kit now supports Safe contracts that use custom-deployed mastercopies (also called singletons), as long as the mastercopy bytecode exactly matches an official Safe version. This enables using the SDK on custom networks, testnets, or with independently deployed Safe contracts.

## How It Works

When you initialize a Safe instance, the SDK will:

1. **First attempt**: Call the `VERSION()` method on the Safe contract to determine its version
2. **Fallback mechanism**: If the VERSION() call fails:
- Read the mastercopy address from storage slot 0 of the Safe proxy
- Fetch the bytecode of the mastercopy contract
- Compare the bytecode hash against all known Safe versions (1.0.0, 1.1.1, 1.2.0, 1.3.0, 1.4.1)
- If a match is found, use that version to initialize the SDK
- If no match is found, fall back to the default version (1.3.0)

## Usage Example

No changes are required to your existing code! The new functionality works transparently:

```typescript
import Safe from '@safe-global/protocol-kit'

// Initialize with a Safe that uses a custom-deployed mastercopy
const safe = await Safe.init({
provider: 'https://your-rpc-url',
signer: privateKey,
safeAddress: '0xYourSafeAddress'
})

// The SDK will automatically:
// 1. Try to call VERSION()
// 2. If that fails, read the mastercopy address
// 3. Match the mastercopy bytecode against known versions
// 4. Initialize with the detected version

console.log(safe.getContractVersion()) // e.g., "1.3.0"
```

## Requirements

For the mastercopy matching to work, the following conditions must be met:

1. **Exact bytecode match**: The mastercopy bytecode must be byte-for-byte identical to an official Safe deployment
2. **Contract must be deployed**: Both the Safe proxy and the mastercopy must be deployed on the network
3. **Supported version**: The mastercopy must match one of the supported Safe versions (1.0.0, 1.1.1, 1.2.0, 1.3.0, or 1.4.1)

## Benefits

- **Custom network support**: Deploy Safes on your own test network using official Safe bytecode
- **Independent deployments**: Use Safes where the mastercopy was deployed separately
- **Automatic version detection**: No need to manually specify the version
- **Backward compatible**: Existing code works without modifications

## What Gets Detected

The mastercopy matching detects:
- **Safe version**: Which Safe contract version (1.0.0, 1.1.1, 1.2.0, 1.3.0, or 1.4.1)
- **Singleton type**: Whether it's an L1 singleton or L2 singleton
- **Mastercopy address**: The address of the matched mastercopy

## Limitations

- Only works with official Safe bytecode (no modified versions)
- The mastercopy must be deployed and accessible on the network
- Performance: The first initialization with a custom mastercopy will require additional RPC calls to fetch and compare bytecode

## Technical Details

For more technical information about the implementation, see [MASTERCOPY_MATCHING.md](../MASTERCOPY_MATCHING.md).

## Troubleshooting

### My Safe initialization fails with "Invalid ... contract address"

This error means the SDK couldn't find a deployment for the contract type on your network. Make sure:
- The Safe proxy is deployed at the specified address
- The mastercopy referenced by the proxy is also deployed
- You're using the correct RPC endpoint for your network

### The detected version is incorrect

If the SDK detects the wrong version, it likely means:
- The mastercopy bytecode has been modified (not an exact match)
- There's an issue with the RPC provider returning incorrect bytecode

You can always manually specify the version using `contractNetworks`:

```typescript
const safe = await Safe.init({
provider: 'https://your-rpc-url',
signer: privateKey,
safeAddress: '0xYourSafeAddress',
contractNetworks: {
[chainId]: {
safeSingletonAddress: '0xYourMastercopyAddress',
safeSingletonAbi: [...] // optional
}
}
})
```

## Example: Using Safe SDK on a Custom Testnet

```typescript
import Safe from '@safe-global/protocol-kit'

// Scenario: You've deployed a Safe on a custom testnet using official v1.3.0 bytecode
// The Safe proxy address is 0x123...
// The mastercopy was deployed at 0xabc...

const safe = await Safe.init({
provider: 'https://custom-testnet-rpc.example.com',
signer: '0xYourPrivateKey',
safeAddress: '0x123...'
})

// The SDK will:
// 1. Call VERSION() on 0x123... (delegates to mastercopy at 0xabc...)
// 2. If that works, use the returned version
// 3. If that fails:
// - Read mastercopy address from storage (gets 0xabc...)
// - Fetch bytecode from 0xabc...
// - Compare with known Safe versions
// - Find it matches v1.3.0
// - Initialize using v1.3.0 ABI

console.log(safe.getContractVersion()) // "1.3.0"

// Now you can use all Safe SDK features normally
const owners = await safe.getOwners()
const threshold = await safe.getThreshold()
// ... etc
```
6 changes: 3 additions & 3 deletions packages/api-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@
],
"homepage": "https://github.com/safe-global/safe-core-sdk#readme",
"devDependencies": {
"@safe-global/relay-kit": "^4.0.5",
"@safe-global/testing-kit": "^0.2.1",
"@safe-global/relay-kit": "^4.1.0",
"@safe-global/testing-kit": "^0.2.2",
"@types/chai": "^4.3.20",
"@types/chai-as-promised": "^7.1.8",
"@types/mocha": "^10.0.10",
Expand All @@ -65,7 +65,7 @@
"tsconfig-paths": "^4.2.0"
},
"dependencies": {
"@safe-global/protocol-kit": "^6.1.0",
"@safe-global/protocol-kit": "^6.1.1",
"@safe-global/types-kit": "^3.0.0",
"node-fetch": "^2.7.0",
"viem": "^2.21.8"
Expand Down
6 changes: 3 additions & 3 deletions packages/protocol-kit/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@safe-global/protocol-kit",
"version": "6.1.0",
"version": "6.1.1",
"description": "SDK that facilitates the interaction with Safe Smart Accounts",
"types": "dist/src/index.d.ts",
"main": "dist/cjs/src/index.cjs",
Expand Down Expand Up @@ -63,7 +63,7 @@
],
"homepage": "https://github.com/safe-global/safe-core-sdk#readme",
"devDependencies": {
"@safe-global/testing-kit": "^0.2.1",
"@safe-global/testing-kit": "^0.2.2",
"@types/chai": "^4.3.20",
"@types/chai-as-promised": "^7.1.8",
"@types/mocha": "^10.0.10",
Expand All @@ -79,7 +79,7 @@
"tsconfig-paths": "^4.2.0"
},
"dependencies": {
"@safe-global/safe-deployments": "^1.37.42",
"@safe-global/safe-deployments": "^1.37.43",
"@safe-global/safe-modules-deployments": "^2.2.14",
"@safe-global/types-kit": "^3.0.0",
"abitype": "^1.0.2",
Expand Down
25 changes: 22 additions & 3 deletions packages/protocol-kit/src/managers/contractManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { SafeVersion } from '@safe-global/types-kit'
import { isSafeConfigWithPredictedSafe } from '../utils/types'
import SafeProvider from '../SafeProvider'
import { getSafeContractVersion } from '@safe-global/protocol-kit/contracts/utils'
import { detectSafeVersionFromMastercopy } from '../utils/mastercopyMatcher'

class ContractManager {
#contractNetworks?: ContractNetworksConfig
Expand Down Expand Up @@ -42,18 +43,36 @@ class ContractManager {
if (isSafeConfigWithPredictedSafe(config)) {
safeVersion = predictedSafe?.safeDeploymentConfig?.safeVersion ?? DEFAULT_SAFE_VERSION
} else {
let detectedVersion: SafeVersion | undefined
let detectedIsL1: boolean | undefined

try {
// We try to fetch the version of the Safe from the blockchain
safeVersion = await getSafeContractVersion(safeProvider, safeAddress as string)
} catch (e) {
// if contract is not deployed we use the default version
safeVersion = DEFAULT_SAFE_VERSION
// If contract is not deployed or VERSION() call fails, try mastercopy matching
const mastercopyMatch = await detectSafeVersionFromMastercopy(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getSafeContractVersion only checks the contract version. The catch clause will not be triggered because the unofficial contract will have the VERSION anyway right?

safeProvider,
safeAddress as string,
chainId,
isL1SafeSingleton
)

if (mastercopyMatch) {
// Successfully matched the mastercopy to a known version
detectedVersion = mastercopyMatch.version
detectedIsL1 = mastercopyMatch.isL1
safeVersion = detectedVersion
} else {
// If no match found, use the default version
safeVersion = DEFAULT_SAFE_VERSION
}
}

this.#safeContract = await getSafeContract({
safeProvider,
safeVersion,
isL1SafeSingleton,
isL1SafeSingleton: detectedIsL1 ?? isL1SafeSingleton,
customSafeAddress: safeAddress,
customContracts
})
Expand Down
2 changes: 1 addition & 1 deletion packages/protocol-kit/src/utils/getProtocolKitVersion.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const getProtocolKitVersion = () => '6.1.0'
export const getProtocolKitVersion = () => '6.1.1'
Loading
Loading