From 79e1a44cfbcf967fdbde6c897f2647f82558e843 Mon Sep 17 00:00:00 2001 From: Daniel <25051234+dasanra@users.noreply.github.com> Date: Wed, 27 Aug 2025 11:32:18 +0200 Subject: [PATCH 01/10] chore: set release versions --- packages/api-kit/package.json | 6 +++--- packages/protocol-kit/package.json | 4 ++-- packages/protocol-kit/src/utils/getProtocolKitVersion.ts | 2 +- packages/relay-kit/package.json | 4 ++-- .../src/packs/safe-4337/utils/getRelayKitVersion.ts | 2 +- packages/sdk-starter-kit/package.json | 6 +++--- packages/testing-kit/package.json | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/api-kit/package.json b/packages/api-kit/package.json index 0d38fb7e3..96635c5ab 100644 --- a/packages/api-kit/package.json +++ b/packages/api-kit/package.json @@ -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", @@ -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" diff --git a/packages/protocol-kit/package.json b/packages/protocol-kit/package.json index bf14d6e8f..c2149601f 100644 --- a/packages/protocol-kit/package.json +++ b/packages/protocol-kit/package.json @@ -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", @@ -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", diff --git a/packages/protocol-kit/src/utils/getProtocolKitVersion.ts b/packages/protocol-kit/src/utils/getProtocolKitVersion.ts index 05c68a6c4..4503e2f4c 100644 --- a/packages/protocol-kit/src/utils/getProtocolKitVersion.ts +++ b/packages/protocol-kit/src/utils/getProtocolKitVersion.ts @@ -1 +1 @@ -export const getProtocolKitVersion = () => '6.1.0' +export const getProtocolKitVersion = () => '6.1.1' diff --git a/packages/relay-kit/package.json b/packages/relay-kit/package.json index 1982b7016..1f96f30de 100644 --- a/packages/relay-kit/package.json +++ b/packages/relay-kit/package.json @@ -1,6 +1,6 @@ { "name": "@safe-global/relay-kit", - "version": "4.0.5", + "version": "4.1.0", "description": "SDK for Safe Smart Accounts with support for ERC-4337 and Relay", "types": "dist/src/index.d.ts", "main": "dist/cjs/src/index.cjs", @@ -56,7 +56,7 @@ }, "dependencies": { "@gelatonetwork/relay-sdk": "^5.6.0", - "@safe-global/protocol-kit": "^6.1.0", + "@safe-global/protocol-kit": "^6.1.1", "@safe-global/safe-modules-deployments": "^2.2.14", "@safe-global/types-kit": "^3.0.0", "semver": "^7.7.2", diff --git a/packages/relay-kit/src/packs/safe-4337/utils/getRelayKitVersion.ts b/packages/relay-kit/src/packs/safe-4337/utils/getRelayKitVersion.ts index ae5e67375..765e15710 100644 --- a/packages/relay-kit/src/packs/safe-4337/utils/getRelayKitVersion.ts +++ b/packages/relay-kit/src/packs/safe-4337/utils/getRelayKitVersion.ts @@ -1 +1 @@ -export const getRelayKitVersion = () => '4.0.5' +export const getRelayKitVersion = () => '4.1.0' diff --git a/packages/sdk-starter-kit/package.json b/packages/sdk-starter-kit/package.json index 126e465d4..5006147ea 100644 --- a/packages/sdk-starter-kit/package.json +++ b/packages/sdk-starter-kit/package.json @@ -1,6 +1,6 @@ { "name": "@safe-global/sdk-starter-kit", - "version": "3.0.0", + "version": "3.0.1", "description": "SDK that provides the basic tools to interact with the Safe Smart Account.", "types": "dist/src/index.d.ts", "main": "dist/cjs/index.cjs", @@ -46,8 +46,8 @@ }, "dependencies": { "@safe-global/api-kit": "^4.0.0", - "@safe-global/protocol-kit": "^6.1.0", - "@safe-global/relay-kit": "^4.0.5", + "@safe-global/protocol-kit": "^6.1.1", + "@safe-global/relay-kit": "^4.1.0", "@safe-global/types-kit": "^3.0.0", "viem": "^2.21.8" } diff --git a/packages/testing-kit/package.json b/packages/testing-kit/package.json index 06986f8c2..ee14c9869 100644 --- a/packages/testing-kit/package.json +++ b/packages/testing-kit/package.json @@ -1,6 +1,6 @@ { "name": "@safe-global/testing-kit", - "version": "0.2.1", + "version": "0.2.2", "description": "Helper package providing testing utilities", "types": "dist/src/src/index.d.ts", "main": "dist/cjs/index.cjs", From 201c50ef97ff5c48661cbe71a013ad7dc2866ada Mon Sep 17 00:00:00 2001 From: Fbartoli Date: Mon, 8 Sep 2025 12:14:15 +0200 Subject: [PATCH 02/10] chore(deps): bump @safe-global/safe-deployments to version 1.37.43 (#1265) --- packages/protocol-kit/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/protocol-kit/package.json b/packages/protocol-kit/package.json index c2149601f..0b811db43 100644 --- a/packages/protocol-kit/package.json +++ b/packages/protocol-kit/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index fdbea1e9f..095c2b694 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1826,10 +1826,10 @@ resolved "https://registry.yarnpkg.com/@safe-global/safe-contracts/-/safe-contracts-1.4.1.tgz#82605342f3289dc6b99818f599a3409ec2cb3fdc" integrity sha512-fP1jewywSwsIniM04NsqPyVRFKPMAuirC3ftA/TA4X3Zc5EnwQp/UCJUU2PL/37/z/jMo8UUaJ+pnFNWmMU7dQ== -"@safe-global/safe-deployments@^1.37.42": - version "1.37.42" - resolved "https://registry.yarnpkg.com/@safe-global/safe-deployments/-/safe-deployments-1.37.42.tgz#dea932a447fe2c76d894df8de4f0aed72ed7fcef" - integrity sha512-hu/GEQhO5lmHsSeVJavtVysdqy16VAumd3pgILN9bTj82ImlHWgkFNYXyeOSbjPvmizdnVVEUzH49yBmoM6oMQ== +"@safe-global/safe-deployments@^1.37.43": + version "1.37.43" + resolved "https://registry.yarnpkg.com/@safe-global/safe-deployments/-/safe-deployments-1.37.43.tgz#18d6e70775ba120b10491a5e49b3ba0abeda36cf" + integrity sha512-kIpMiQdGPiNn390cxuSA9a681jaoEMvz79grkmrb2eTJq4DjGbirtYRFDC6Pt6hZ75GE57/MhQe2MVF5RQy+rA== dependencies: semver "^7.6.2" From d7486b1def34109c36bdbb500e1e77b16cb14904 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Sep 2025 15:13:30 +0000 Subject: [PATCH 03/10] Initial plan From d22c1e25becff19f2868e7b04f8f4e4f49125190 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Sep 2025 15:23:24 +0000 Subject: [PATCH 04/10] Add mastercopy codehash matching utilities Co-authored-by: katspaugh <381895+katspaugh@users.noreply.github.com> --- .../src/managers/contractManager.ts | 25 ++- .../src/utils/mastercopyMatcher.ts | 143 ++++++++++++++++++ 2 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 packages/protocol-kit/src/utils/mastercopyMatcher.ts diff --git a/packages/protocol-kit/src/managers/contractManager.ts b/packages/protocol-kit/src/managers/contractManager.ts index 651a66fa1..35662c3b0 100644 --- a/packages/protocol-kit/src/managers/contractManager.ts +++ b/packages/protocol-kit/src/managers/contractManager.ts @@ -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,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( + 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 }) diff --git a/packages/protocol-kit/src/utils/mastercopyMatcher.ts b/packages/protocol-kit/src/utils/mastercopyMatcher.ts new file mode 100644 index 000000000..d50176244 --- /dev/null +++ b/packages/protocol-kit/src/utils/mastercopyMatcher.ts @@ -0,0 +1,143 @@ +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 { + // 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
+ const address = '0x' + storage.slice(-40) + + return safeProvider.getChecksummedAddress(address) +} + +/** + * Attempts to match a contract's bytecode hash against all known Safe singleton versions. + * + * @param safeProvider - The SafeProvider instance + * @param contractAddress - The address of the contract to check + * @param chainId - The chain ID + * @param isL1SafeSingleton - Whether to check L1 or L2 singleton contracts + * @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, + isL1SafeSingleton?: boolean +): 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}`) + + // List of Safe versions to check (from newest to oldest) + const versionsToCheck: SafeVersion[] = ['1.4.1', '1.3.0', '1.2.0', '1.1.1', '1.0.0'] + + // Try to match against each version + for (const version of versionsToCheck) { + // Check both L1 and L2 versions, prioritizing based on isL1SafeSingleton hint + const contractNamesOrder = + isL1SafeSingleton !== false + ? [ + { name: 'safeSingletonVersion' as const, isL1: true }, + { name: 'safeSingletonL2Version' as const, isL1: false } + ] + : [ + { name: 'safeSingletonL2Version' as const, isL1: false }, + { name: 'safeSingletonVersion' as const, isL1: true } + ] + + for (const { name: contractName, isL1 } of contractNamesOrder) { + try { + const deployment = getContractDeployment(version, chainId, contractName) + + 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 } + } + } + } + } 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. + * + * @param safeProvider - The SafeProvider instance + * @param safeAddress - The address of the Safe proxy + * @param chainId - The chain ID + * @param isL1SafeSingleton - Whether to check L1 or L2 singleton contracts + * @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, + isL1SafeSingleton?: boolean +): 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 version + const matchResult = await matchContractCodeToSafeVersion( + safeProvider, + mastercopyAddress, + chainId, + isL1SafeSingleton + ) + + if (matchResult) { + return { + version: matchResult.version, + mastercopyAddress, + isL1: matchResult.isL1 + } + } + + return undefined + } catch (e) { + // If any error occurs during detection, return undefined + return undefined + } +} From 7ee4f86f025a054e9b592ffadd7fa3c1a0abd796 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Sep 2025 15:26:04 +0000 Subject: [PATCH 05/10] Add unit tests for mastercopy matching functionality --- .../tests/unit/mastercopy-matcher.test.ts | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 packages/protocol-kit/tests/unit/mastercopy-matcher.test.ts diff --git a/packages/protocol-kit/tests/unit/mastercopy-matcher.test.ts b/packages/protocol-kit/tests/unit/mastercopy-matcher.test.ts new file mode 100644 index 000000000..608f02d6b --- /dev/null +++ b/packages/protocol-kit/tests/unit/mastercopy-matcher.test.ts @@ -0,0 +1,138 @@ +import chai from 'chai' +import sinon from 'sinon' +import SafeProvider from '@safe-global/protocol-kit/SafeProvider' +import { + getMasterCopyAddressFromProxy, + matchContractCodeToSafeVersion, + detectSafeVersionFromMastercopy +} from '@safe-global/protocol-kit/utils/mastercopyMatcher' + +declare module 'abitype' { + export interface Register { + AddressType: string + } +} + +describe('Mastercopy Matcher', () => { + let safeProvider: sinon.SinonStubbedInstance + + beforeEach(() => { + // Create a stub SafeProvider for testing + safeProvider = { + getStorageAt: sinon.stub(), + getContractCode: sinon.stub(), + getChecksummedAddress: sinon.stub().callsFake((addr: string) => addr) + } as sinon.SinonStubbedInstance + }) + + describe('getMasterCopyAddressFromProxy', () => { + it('should extract mastercopy address from storage slot 0', async () => { + const expectedAddress = '0x1234567890123456789012345678901234567890' + const storageValue = '0x000000000000000000000000' + expectedAddress.slice(2) + + ;(safeProvider.getStorageAt as sinon.SinonStub).resolves(storageValue) + + const result = await getMasterCopyAddressFromProxy(safeProvider, '0xSafeAddress') + + chai.expect(result).to.equal(expectedAddress) + chai.expect((safeProvider.getStorageAt as sinon.SinonStub).calledWith('0xSafeAddress', '0x0')) + .to.be.true + }) + + it('should handle storage values with different padding', async () => { + const expectedAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' + const storageValue = '0x00000000' + expectedAddress.slice(2) + + ;(safeProvider.getStorageAt as sinon.SinonStub).resolves(storageValue) + + const result = await getMasterCopyAddressFromProxy(safeProvider, '0xSafeAddress') + + chai.expect(result).to.equal(expectedAddress) + }) + }) + + describe('matchContractCodeToSafeVersion', () => { + it('should return undefined if contract code is empty', async () => { + ;(safeProvider.getContractCode as sinon.SinonStub).resolves('0x') + + const result = await matchContractCodeToSafeVersion( + safeProvider, + '0xContractAddress', + 1n, + true + ) + + chai.expect(result).to.be.undefined + }) + + it('should return undefined if contract code is null', async () => { + ;(safeProvider.getContractCode as sinon.SinonStub).resolves(null) + + const result = await matchContractCodeToSafeVersion( + safeProvider, + '0xContractAddress', + 1n, + true + ) + + chai.expect(result).to.be.undefined + }) + + it('should match contract code against known Safe versions', async () => { + // This test would require actual contract bytecode from safe-deployments + // For now, we test that it correctly computes the hash and tries to match + const mockCode = '0x1234567890abcdef' + ;(safeProvider.getContractCode as sinon.SinonStub).resolves(mockCode) + + const result = await matchContractCodeToSafeVersion( + safeProvider, + '0xContractAddress', + 1n, + true + ) + + // Since we're using mock code that won't match any real Safe, expect undefined + chai.expect(result).to.be.undefined + chai.expect((safeProvider.getContractCode as sinon.SinonStub).calledOnce).to.be.true + }) + }) + + describe('detectSafeVersionFromMastercopy', () => { + it('should return undefined if mastercopy detection fails', async () => { + ;(safeProvider.getStorageAt as sinon.SinonStub).rejects(new Error('Network error')) + + const result = await detectSafeVersionFromMastercopy(safeProvider, '0xSafeAddress', 1n, true) + + chai.expect(result).to.be.undefined + }) + + it('should return undefined if no matching version is found', async () => { + const mastercopyAddress = '0x1234567890123456789012345678901234567890' + const storageValue = '0x000000000000000000000000' + mastercopyAddress.slice(2) + + ;(safeProvider.getStorageAt as sinon.SinonStub).resolves(storageValue) + ;(safeProvider.getContractCode as sinon.SinonStub).resolves('0x1234567890abcdef') + + const result = await detectSafeVersionFromMastercopy(safeProvider, '0xSafeAddress', 1n, true) + + // Since we're using mock code that won't match any real Safe, expect undefined + chai.expect(result).to.be.undefined + }) + + it('should return version and mastercopy address if match is found', async () => { + // This test would need real Safe bytecode to properly test + // For demonstration, we show the expected structure + const mastercopyAddress = '0x1234567890123456789012345678901234567890' + const storageValue = '0x000000000000000000000000' + mastercopyAddress.slice(2) + + ;(safeProvider.getStorageAt as sinon.SinonStub).resolves(storageValue) + + // If a match were found, it would return an object with version, mastercopyAddress, and isL1 + // For this test with mock data, it will return undefined + const result = await detectSafeVersionFromMastercopy(safeProvider, '0xSafeAddress', 1n, true) + + // With mock data, no match is found + chai.expect(result).to.be.undefined + }) + }) +}) From ad103e6de39165d1d25daa060e5c742d52bd3210 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Sep 2025 15:28:44 +0000 Subject: [PATCH 06/10] Add documentation for mastercopy code matching feature Co-authored-by: katspaugh <381895+katspaugh@users.noreply.github.com> --- MASTERCOPY_MATCHING.md | 88 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 MASTERCOPY_MATCHING.md diff --git a/MASTERCOPY_MATCHING.md b/MASTERCOPY_MATCHING.md new file mode 100644 index 000000000..61889d02c --- /dev/null +++ b/MASTERCOPY_MATCHING.md @@ -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 From fd040a011124ec28790c8b018c8832e56ce8730a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Sep 2025 15:30:23 +0000 Subject: [PATCH 07/10] Add user guide for custom mastercopy support Co-authored-by: katspaugh <381895+katspaugh@users.noreply.github.com> --- docs/CUSTOM_MASTERCOPY_GUIDE.md | 136 ++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 docs/CUSTOM_MASTERCOPY_GUIDE.md diff --git a/docs/CUSTOM_MASTERCOPY_GUIDE.md b/docs/CUSTOM_MASTERCOPY_GUIDE.md new file mode 100644 index 000000000..10952695d --- /dev/null +++ b/docs/CUSTOM_MASTERCOPY_GUIDE.md @@ -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 +``` From e4d052f9c793df35c1b43054e26ffb275c1d07d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 07:00:34 +0000 Subject: [PATCH 08/10] Remove MASTERCOPY_MATCHING.md and update references Co-authored-by: katspaugh <381895+katspaugh@users.noreply.github.com> --- MASTERCOPY_MATCHING.md | 88 --------------------------------- docs/CUSTOM_MASTERCOPY_GUIDE.md | 4 -- 2 files changed, 92 deletions(-) delete mode 100644 MASTERCOPY_MATCHING.md diff --git a/MASTERCOPY_MATCHING.md b/MASTERCOPY_MATCHING.md deleted file mode 100644 index 61889d02c..000000000 --- a/MASTERCOPY_MATCHING.md +++ /dev/null @@ -1,88 +0,0 @@ -# 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 diff --git a/docs/CUSTOM_MASTERCOPY_GUIDE.md b/docs/CUSTOM_MASTERCOPY_GUIDE.md index 10952695d..6307dd979 100644 --- a/docs/CUSTOM_MASTERCOPY_GUIDE.md +++ b/docs/CUSTOM_MASTERCOPY_GUIDE.md @@ -67,10 +67,6 @@ The mastercopy matching detects: - 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" From d4b737081f8cd740a76d9bbcfc5b43f734b0a0bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 07:10:03 +0000 Subject: [PATCH 09/10] Limit mastercopy matching to 1.1.1 L2 and 1.3.0 L2 only Co-authored-by: katspaugh <381895+katspaugh@users.noreply.github.com> --- docs/CUSTOM_MASTERCOPY_GUIDE.md | 27 ++++--- .../src/managers/contractManager.ts | 7 +- .../src/utils/mastercopyMatcher.ts | 73 +++++++------------ .../tests/unit/mastercopy-matcher.test.ts | 42 ++++------- 4 files changed, 60 insertions(+), 89 deletions(-) diff --git a/docs/CUSTOM_MASTERCOPY_GUIDE.md b/docs/CUSTOM_MASTERCOPY_GUIDE.md index 6307dd979..55d8faa31 100644 --- a/docs/CUSTOM_MASTERCOPY_GUIDE.md +++ b/docs/CUSTOM_MASTERCOPY_GUIDE.md @@ -2,7 +2,9 @@ ## 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. +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 @@ -12,7 +14,7 @@ When you initialize a Safe instance, the SDK will: 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) + - 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) @@ -43,26 +45,27 @@ console.log(safe.getContractVersion()) // e.g., "1.3.0" 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 +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 versions (1.0.0, 1.1.1, 1.2.0, 1.3.0, or 1.4.1) +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 bytecode -- **Independent deployments**: Use Safes where the mastercopy was deployed separately +- **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 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 +- **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 @@ -103,9 +106,9 @@ const safe = await Safe.init({ ```typescript import Safe from '@safe-global/protocol-kit' -// Scenario: You've deployed a Safe on a custom testnet using official v1.3.0 bytecode +// Scenario: You've deployed a Safe on a custom testnet using official v1.3.0 L2 bytecode // The Safe proxy address is 0x123... -// The mastercopy was deployed at 0xabc... +// The L2 mastercopy was deployed at 0xabc... const safe = await Safe.init({ provider: 'https://custom-testnet-rpc.example.com', @@ -119,8 +122,8 @@ const safe = await Safe.init({ // 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 +// - 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" diff --git a/packages/protocol-kit/src/managers/contractManager.ts b/packages/protocol-kit/src/managers/contractManager.ts index 35662c3b0..90d1aaf1c 100644 --- a/packages/protocol-kit/src/managers/contractManager.ts +++ b/packages/protocol-kit/src/managers/contractManager.ts @@ -50,16 +50,15 @@ class ContractManager { // 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 or VERSION() call fails, try mastercopy matching + // If contract is not deployed or VERSION() call fails, try mastercopy matching (L2 only) const mastercopyMatch = await detectSafeVersionFromMastercopy( safeProvider, safeAddress as string, - chainId, - isL1SafeSingleton + chainId ) if (mastercopyMatch) { - // Successfully matched the mastercopy to a known version + // Successfully matched the mastercopy to a known L2 version detectedVersion = mastercopyMatch.version detectedIsL1 = mastercopyMatch.isL1 safeVersion = detectedVersion diff --git a/packages/protocol-kit/src/utils/mastercopyMatcher.ts b/packages/protocol-kit/src/utils/mastercopyMatcher.ts index d50176244..a0ab2d051 100644 --- a/packages/protocol-kit/src/utils/mastercopyMatcher.ts +++ b/packages/protocol-kit/src/utils/mastercopyMatcher.ts @@ -26,19 +26,18 @@ export async function getMasterCopyAddressFromProxy( } /** - * Attempts to match a contract's bytecode hash against all known Safe singleton versions. + * 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 - * @param isL1SafeSingleton - Whether to check L1 or L2 singleton contracts * @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, - isL1SafeSingleton?: boolean + chainId: bigint ): Promise<{ version: SafeVersion; isL1: boolean } | undefined> { // Get the bytecode of the contract const contractCode = await safeProvider.getContractCode(contractAddress) @@ -50,49 +49,35 @@ export async function matchContractCodeToSafeVersion( // Compute the keccak256 hash of the bytecode const contractCodeHash = keccak256(contractCode as `0x${string}`) - // List of Safe versions to check (from newest to oldest) - const versionsToCheck: SafeVersion[] = ['1.4.1', '1.3.0', '1.2.0', '1.1.1', '1.0.0'] + // 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 + // Try to match against each version - L2 only for (const version of versionsToCheck) { - // Check both L1 and L2 versions, prioritizing based on isL1SafeSingleton hint - const contractNamesOrder = - isL1SafeSingleton !== false - ? [ - { name: 'safeSingletonVersion' as const, isL1: true }, - { name: 'safeSingletonL2Version' as const, isL1: false } - ] - : [ - { name: 'safeSingletonL2Version' as const, isL1: false }, - { name: 'safeSingletonVersion' as const, isL1: true } - ] - - for (const { name: contractName, isL1 } of contractNamesOrder) { - try { - const deployment = getContractDeployment(version, chainId, contractName) - - if (!deployment || !('deployments' in deployment)) { - continue - } + try { + const deployment = getContractDeployment(version, chainId, 'safeSingletonL2Version') - // 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 (!deployment || !('deployments' in deployment)) { + continue + } - if (deploymentInfo && 'codeHash' in deploymentInfo) { - const deployedCodeHash = deploymentInfo.codeHash + // 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 (deployedCodeHash === contractCodeHash) { - // Found a match! - return { version, isL1 } - } + 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 } + } catch (e) { + // If deployment doesn't exist for this version/chain, continue + continue } } @@ -102,29 +87,27 @@ export async function matchContractCodeToSafeVersion( /** * 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 - * @param isL1SafeSingleton - Whether to check L1 or L2 singleton contracts * @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, - isL1SafeSingleton?: boolean + 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 version + // Try to match the mastercopy code to a known Safe L2 version const matchResult = await matchContractCodeToSafeVersion( safeProvider, mastercopyAddress, - chainId, - isL1SafeSingleton + chainId ) if (matchResult) { diff --git a/packages/protocol-kit/tests/unit/mastercopy-matcher.test.ts b/packages/protocol-kit/tests/unit/mastercopy-matcher.test.ts index 608f02d6b..15e6aeef4 100644 --- a/packages/protocol-kit/tests/unit/mastercopy-matcher.test.ts +++ b/packages/protocol-kit/tests/unit/mastercopy-matcher.test.ts @@ -55,12 +55,7 @@ describe('Mastercopy Matcher', () => { it('should return undefined if contract code is empty', async () => { ;(safeProvider.getContractCode as sinon.SinonStub).resolves('0x') - const result = await matchContractCodeToSafeVersion( - safeProvider, - '0xContractAddress', - 1n, - true - ) + const result = await matchContractCodeToSafeVersion(safeProvider, '0xContractAddress', 1n) chai.expect(result).to.be.undefined }) @@ -68,30 +63,21 @@ describe('Mastercopy Matcher', () => { it('should return undefined if contract code is null', async () => { ;(safeProvider.getContractCode as sinon.SinonStub).resolves(null) - const result = await matchContractCodeToSafeVersion( - safeProvider, - '0xContractAddress', - 1n, - true - ) + const result = await matchContractCodeToSafeVersion(safeProvider, '0xContractAddress', 1n) chai.expect(result).to.be.undefined }) - it('should match contract code against known Safe versions', async () => { + it('should match contract code against supported Safe L2 versions', async () => { // This test would require actual contract bytecode from safe-deployments // For now, we test that it correctly computes the hash and tries to match + // Only 1.1.1 L2 and 1.3.0 L2 are supported const mockCode = '0x1234567890abcdef' ;(safeProvider.getContractCode as sinon.SinonStub).resolves(mockCode) - const result = await matchContractCodeToSafeVersion( - safeProvider, - '0xContractAddress', - 1n, - true - ) + const result = await matchContractCodeToSafeVersion(safeProvider, '0xContractAddress', 1n) - // Since we're using mock code that won't match any real Safe, expect undefined + // Since we're using mock code that won't match any real Safe L2, expect undefined chai.expect(result).to.be.undefined chai.expect((safeProvider.getContractCode as sinon.SinonStub).calledOnce).to.be.true }) @@ -101,35 +87,35 @@ describe('Mastercopy Matcher', () => { it('should return undefined if mastercopy detection fails', async () => { ;(safeProvider.getStorageAt as sinon.SinonStub).rejects(new Error('Network error')) - const result = await detectSafeVersionFromMastercopy(safeProvider, '0xSafeAddress', 1n, true) + const result = await detectSafeVersionFromMastercopy(safeProvider, '0xSafeAddress', 1n) chai.expect(result).to.be.undefined }) - it('should return undefined if no matching version is found', async () => { + it('should return undefined if no matching L2 version is found', async () => { const mastercopyAddress = '0x1234567890123456789012345678901234567890' const storageValue = '0x000000000000000000000000' + mastercopyAddress.slice(2) ;(safeProvider.getStorageAt as sinon.SinonStub).resolves(storageValue) ;(safeProvider.getContractCode as sinon.SinonStub).resolves('0x1234567890abcdef') - const result = await detectSafeVersionFromMastercopy(safeProvider, '0xSafeAddress', 1n, true) + const result = await detectSafeVersionFromMastercopy(safeProvider, '0xSafeAddress', 1n) - // Since we're using mock code that won't match any real Safe, expect undefined + // Since we're using mock code that won't match any real Safe L2 (1.1.1 or 1.3.0), expect undefined chai.expect(result).to.be.undefined }) - it('should return version and mastercopy address if match is found', async () => { - // This test would need real Safe bytecode to properly test + it('should return version and mastercopy address if L2 match is found', async () => { + // This test would need real Safe L2 bytecode to properly test // For demonstration, we show the expected structure const mastercopyAddress = '0x1234567890123456789012345678901234567890' const storageValue = '0x000000000000000000000000' + mastercopyAddress.slice(2) ;(safeProvider.getStorageAt as sinon.SinonStub).resolves(storageValue) - // If a match were found, it would return an object with version, mastercopyAddress, and isL1 + // If a match were found for 1.1.1 L2 or 1.3.0 L2, it would return an object with version, mastercopyAddress, and isL1=false // For this test with mock data, it will return undefined - const result = await detectSafeVersionFromMastercopy(safeProvider, '0xSafeAddress', 1n, true) + const result = await detectSafeVersionFromMastercopy(safeProvider, '0xSafeAddress', 1n) // With mock data, no match is found chai.expect(result).to.be.undefined From fc2ca882b2125aad941ff052ce624aa062f43b97 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 08:51:27 +0000 Subject: [PATCH 10/10] Change supported versions to 1.3.0 L2 and 1.4.1 L2 Co-authored-by: katspaugh <381895+katspaugh@users.noreply.github.com> --- docs/CUSTOM_MASTERCOPY_GUIDE.md | 12 ++++++------ packages/protocol-kit/src/utils/mastercopyMatcher.ts | 8 ++++---- .../tests/unit/mastercopy-matcher.test.ts | 6 +++--- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/CUSTOM_MASTERCOPY_GUIDE.md b/docs/CUSTOM_MASTERCOPY_GUIDE.md index 55d8faa31..2e4bbfa2d 100644 --- a/docs/CUSTOM_MASTERCOPY_GUIDE.md +++ b/docs/CUSTOM_MASTERCOPY_GUIDE.md @@ -4,7 +4,7 @@ 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. +**Supported versions**: Only **1.3.0 L2** and **1.4.1 L2** mastercopies are supported for bytecode matching. ## How It Works @@ -14,7 +14,7 @@ When you initialize a Safe instance, the SDK will: 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) + - Compare the bytecode hash against supported Safe L2 versions (1.3.0 L2 and 1.4.1 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) @@ -47,7 +47,7 @@ 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) +3. **Supported version**: The mastercopy must match one of the supported Safe L2 versions (**1.3.0 L2** or **1.4.1 L2** only) ## Benefits @@ -59,13 +59,13 @@ For the mastercopy matching to work, the following conditions must be met: ## What Gets Detected The mastercopy matching detects: -- **Safe version**: Which Safe L2 contract version (1.1.1 or 1.3.0) +- **Safe version**: Which Safe L2 contract version (1.3.0 or 1.4.1) - **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 L2 versions supported**: Only 1.3.0 L2 and 1.4.1 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 @@ -122,7 +122,7 @@ const safe = await Safe.init({ // 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) +// - Compare with supported Safe L2 versions (1.3.0 L2 and 1.4.1 L2) // - Find it matches v1.3.0 L2 // - Initialize using v1.3.0 ABI diff --git a/packages/protocol-kit/src/utils/mastercopyMatcher.ts b/packages/protocol-kit/src/utils/mastercopyMatcher.ts index a0ab2d051..3198a29d3 100644 --- a/packages/protocol-kit/src/utils/mastercopyMatcher.ts +++ b/packages/protocol-kit/src/utils/mastercopyMatcher.ts @@ -27,7 +27,7 @@ export async function getMasterCopyAddressFromProxy( /** * 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. + * Only supports 1.3.0 L2 and 1.4.1 L2 mastercopies. * * @param safeProvider - The SafeProvider instance * @param contractAddress - The address of the contract to check @@ -49,8 +49,8 @@ export async function matchContractCodeToSafeVersion( // 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'] + // Only check 1.3.0 L2 and 1.4.1 L2 versions + const versionsToCheck: SafeVersion[] = ['1.4.1', '1.3.0'] // Try to match against each version - L2 only for (const version of versionsToCheck) { @@ -87,7 +87,7 @@ export async function matchContractCodeToSafeVersion( /** * 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. + * Only supports 1.3.0 L2 and 1.4.1 L2 mastercopies. * * @param safeProvider - The SafeProvider instance * @param safeAddress - The address of the Safe proxy diff --git a/packages/protocol-kit/tests/unit/mastercopy-matcher.test.ts b/packages/protocol-kit/tests/unit/mastercopy-matcher.test.ts index 15e6aeef4..24060cd96 100644 --- a/packages/protocol-kit/tests/unit/mastercopy-matcher.test.ts +++ b/packages/protocol-kit/tests/unit/mastercopy-matcher.test.ts @@ -71,7 +71,7 @@ describe('Mastercopy Matcher', () => { it('should match contract code against supported Safe L2 versions', async () => { // This test would require actual contract bytecode from safe-deployments // For now, we test that it correctly computes the hash and tries to match - // Only 1.1.1 L2 and 1.3.0 L2 are supported + // Only 1.3.0 L2 and 1.4.1 L2 are supported const mockCode = '0x1234567890abcdef' ;(safeProvider.getContractCode as sinon.SinonStub).resolves(mockCode) @@ -101,7 +101,7 @@ describe('Mastercopy Matcher', () => { const result = await detectSafeVersionFromMastercopy(safeProvider, '0xSafeAddress', 1n) - // Since we're using mock code that won't match any real Safe L2 (1.1.1 or 1.3.0), expect undefined + // Since we're using mock code that won't match any real Safe L2 (1.3.0 or 1.4.1), expect undefined chai.expect(result).to.be.undefined }) @@ -113,7 +113,7 @@ describe('Mastercopy Matcher', () => { ;(safeProvider.getStorageAt as sinon.SinonStub).resolves(storageValue) - // If a match were found for 1.1.1 L2 or 1.3.0 L2, it would return an object with version, mastercopyAddress, and isL1=false + // If a match were found for 1.3.0 L2 or 1.4.1 L2, it would return an object with version, mastercopyAddress, and isL1=false // For this test with mock data, it will return undefined const result = await detectSafeVersionFromMastercopy(safeProvider, '0xSafeAddress', 1n)