-
Notifications
You must be signed in to change notification settings - Fork 278
feat(protocol-kit): Add mastercopy bytecode matching for L2 deployments #1271
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Copilot
wants to merge
10
commits into
development
from
copilot/fix-f31fb0b7-80b9-45b5-935a-c6950afea8c6
Closed
Changes from 9 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
79e1a44
chore: set release versions
dasanra 201c50e
chore(deps): bump @safe-global/safe-deployments to version 1.37.43 (#…
Fbartoli d7486b1
Initial plan
Copilot d22c1e2
Add mastercopy codehash matching utilities
Copilot 7ee4f86
Add unit tests for mastercopy matching functionality
Copilot ad103e6
Add documentation for mastercopy code matching feature
Copilot fd040a0
Add user guide for custom mastercopy support
Copilot e4d052f
Remove MASTERCOPY_MATCHING.md and update references
Copilot d4b7370
Limit mastercopy matching to 1.1.1 L2 and 1.3.0 L2 only
Copilot fc2ca88
Change supported versions to 1.3.0 L2 and 1.4.1 L2
Copilot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
- 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 | ||
``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
}) | ||
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1 @@ | ||
export const getProtocolKitVersion = () => '6.1.0' | ||
export const getProtocolKitVersion = () => '6.1.1' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
katspaugh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return undefined | ||
} | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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).