Skip to content
Closed
Show file tree
Hide file tree
Changes from 9 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
135 changes: 135 additions & 0 deletions docs/CUSTOM_MASTERCOPY_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Using Safe SDK with Custom Mastercopy Deployments

## Overview

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

**Supported versions**: Only **1.1.1 L2** and **1.3.0 L2** mastercopies are supported for bytecode matching.

## 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 supported Safe L2 versions (1.1.1 L2 and 1.3.0 L2)
Copy link

Choose a reason for hiding this comment

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

AFAIU, the feature should be limited to v1.3.0 and v1.4.1 (and not v1.1.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 L2 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 L2 versions (**1.1.1 L2** or **1.3.0 L2** only)

## Benefits

- **Custom network support**: Deploy Safes on your own test network using official Safe L2 bytecode
- **Independent deployments**: Use Safes where the L2 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 L2 contract version (1.1.1 or 1.3.0)
- **Singleton type**: Always L2 singleton
- **Mastercopy address**: The address of the matched mastercopy

## Limitations

- **Only L2 versions supported**: Only 1.1.1 L2 and 1.3.0 L2 mastercopies are supported
- 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

## 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 L2 bytecode
// The Safe proxy address is 0x123...
// The L2 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 supported Safe L2 versions (1.1.1 L2 and 1.3.0 L2)
// - Find it matches v1.3.0 L2
// - 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
24 changes: 21 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,35 @@ 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 (L2 only)
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
)

if (mastercopyMatch) {
// Successfully matched the mastercopy to a known L2 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'
126 changes: 126 additions & 0 deletions packages/protocol-kit/src/utils/mastercopyMatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { keccak256 } from 'viem'
import SafeProvider from '@safe-global/protocol-kit/SafeProvider'
import { SafeVersion } from '@safe-global/types-kit'
import { getContractDeployment } from '@safe-global/protocol-kit/contracts/config'

/**
* Reads the mastercopy address from a Safe proxy contract.
* The mastercopy address is stored at storage slot 0.
*
* @param safeProvider - The SafeProvider instance
* @param safeAddress - The address of the Safe proxy
* @returns The mastercopy address
*/
export async function getMasterCopyAddressFromProxy(
safeProvider: SafeProvider,
safeAddress: string
): Promise<string> {
// Read storage at slot 0, which contains the mastercopy address
const storage = await safeProvider.getStorageAt(safeAddress, '0x0')

// The address is stored in the last 20 bytes (40 hex characters)
// Format: 0x000000000000000000000000<address>
const address = '0x' + storage.slice(-40)

return safeProvider.getChecksummedAddress(address)
}

/**
* Attempts to match a contract's bytecode hash against supported Safe L2 singleton versions.
* Only supports 1.1.1 L2 and 1.3.0 L2 mastercopies.
*
* @param safeProvider - The SafeProvider instance
* @param contractAddress - The address of the contract to check
* @param chainId - The chain ID
* @returns An object with the matched SafeVersion and whether it's L1, or undefined if no match is found
*/
export async function matchContractCodeToSafeVersion(
safeProvider: SafeProvider,
contractAddress: string,
chainId: bigint
): Promise<{ version: SafeVersion; isL1: boolean } | undefined> {
// Get the bytecode of the contract
const contractCode = await safeProvider.getContractCode(contractAddress)

if (!contractCode || contractCode === '0x') {
return undefined
}

// Compute the keccak256 hash of the bytecode
const contractCodeHash = keccak256(contractCode as `0x${string}`)

// Only check 1.1.1 L2 and 1.3.0 L2 versions
const versionsToCheck: SafeVersion[] = ['1.3.0', '1.1.1']

// Try to match against each version - L2 only
for (const version of versionsToCheck) {
try {
const deployment = getContractDeployment(version, chainId, 'safeSingletonL2Version')

if (!deployment || !('deployments' in deployment)) {
continue
}

// Check all deployment types (canonical, eip155, etc.)
for (const deploymentType of Object.keys(deployment.deployments)) {
const deploymentInfo =
deployment.deployments[deploymentType as keyof typeof deployment.deployments]

if (deploymentInfo && 'codeHash' in deploymentInfo) {
const deployedCodeHash = deploymentInfo.codeHash

if (deployedCodeHash === contractCodeHash) {
// Found a match!
return { version, isL1: false }
}
}
}
} catch (e) {
// If deployment doesn't exist for this version/chain, continue
continue
}
}

return undefined
}

/**
* Attempts to determine the Safe version by matching the mastercopy code.
* This is used as a fallback when the Safe address is not in the safe-deployments package.
* Only supports 1.1.1 L2 and 1.3.0 L2 mastercopies.
*
* @param safeProvider - The SafeProvider instance
* @param safeAddress - The address of the Safe proxy
* @param chainId - The chain ID
* @returns An object containing the matched version, mastercopy address, and L1 flag, or undefined if no match
*/
export async function detectSafeVersionFromMastercopy(
safeProvider: SafeProvider,
safeAddress: string,
chainId: bigint
): Promise<{ version: SafeVersion; mastercopyAddress: string; isL1: boolean } | undefined> {
try {
// Get the mastercopy address from the Safe proxy
const mastercopyAddress = await getMasterCopyAddressFromProxy(safeProvider, safeAddress)

// Try to match the mastercopy code to a known Safe L2 version
const matchResult = await matchContractCodeToSafeVersion(
safeProvider,
mastercopyAddress,
chainId
)

if (matchResult) {
return {
version: matchResult.version,
mastercopyAddress,
isL1: matchResult.isL1
}
}

return undefined
} catch (e) {
// If any error occurs during detection, return undefined
return undefined
}
}
Loading
Loading