Skip to content

Instantly share code, notes, and snippets.

@bh2smith
Last active June 28, 2024 13:25
Show Gist options
  • Save bh2smith/27f177a627a0e8679847b857c4d40d66 to your computer and use it in GitHub Desktop.
Save bh2smith/27f177a627a0e8679847b857c4d40d66 to your computer and use it in GitHub Desktop.
Safe SDK Feedback

Safe SDK Notes:

Use Case

Let us suppose our SIGNER_ADDRESS=0xf11c22d61ecd7b1adcb6b43542fe8a96b9328dc7 and that we do not have the private key, but we can produce signatures for this account.

Can't initialize the safe pack for usecase:

https://docs.safe.global/sdk/relay-kit/guides/4337-safe-sdk#initialize-the-safe4337pack

  1. Requires a Private Key: I can produce signatures, but not a private key
  2. Having to choose between New vs Existing Safe Account. The init code could determine this fact based on the setup data provided
  3. calculateSafeOpHash could be part of the kit
  4. Can not add signature

More details below:

2. SafePack Init Issues

{
  owners: string[],
  threshold: number,
  safeSaltNonce: string
}

Instead, I need to load up a few contracts (singleton, moduleSetup, m4337, proxyFactory) and do all of this to determine the safe address and then check if its deployed to determine how to initialize the safePack

Get Deterministic Safe Address
const setup = await singleton.interface.encodeFunctionData("setup", [
  owners,
  1, // threshold
  moduleSetup.target,
  moduleSetup.interface.encodeFunctionData("enableModules", [
    [m4337.target],
  ]),
  m4337.target,
  ethers.ZeroAddress,
  0,
  ethers.ZeroAddress,
]);


async addressForSetup(
  setup: ethers.BytesLike,
  saltNonce?: string,
): Promise<ethers.AddressLike> {
  // bytes32 salt = keccak256(abi.encodePacked(keccak256(initializer), saltNonce));
  // cf: https://github.com/safe-global/safe-smart-account/blob/499b17ad0191b575fcadc5cb5b8e3faeae5391ae/contracts/proxies/SafeProxyFactory.sol#L58
  const salt = ethers.keccak256(
    ethers.solidityPacked(
      ["bytes32", "uint256"],
      [ethers.keccak256(setup), saltNonce || 0],
    ),
  );

  // abi.encodePacked(type(SafeProxy).creationCode, uint256(uint160(_singleton)));
  // cf: https://github.com/safe-global/safe-smart-account/blob/499b17ad0191b575fcadc5cb5b8e3faeae5391ae/contracts/proxies/SafeProxyFactory.sol#L29
  const initCode = ethers.solidityPacked(
    ["bytes", "uint256"],
    [
      await this.proxyFactory.proxyCreationCode(),
      await this.singleton.getAddress(),
    ],
  );
  return ethers.getCreate2Address(
    await this.proxyFactory.getAddress(),
    salt,
    ethers.keccak256(initCode),
  )

So, we us use the Safe API to determine if there are any safes with threshold 1 having SIGNER_ADDRESS as an owner. We also need to check the fallback handler is a Safe4337Module

a) Safe API Requires Checksum Addresses (why?)

b) https://safe-transaction-sepolia.safe.global/api/v1/owners/0xF11c22D61ecd7b1adCB6b43542fe8a96b9328dC7/safes/

c) https://safe-transaction-sepolia.safe.global/api/v1/safes/0x8B8f22ca3A7180274D573d4cC3C4F4F08bCEE0AC/

Cool, so we found one -- We can go with "Existing Safe", but... SIGNER_PRIVATE_KEY (oh no!)

const safe4337Pack = await Safe4337Pack.init({
  provider: RPC_URL,
  signer: SIGNER_PRIVATE_KEY,
  bundlerUrl: `https://api.pimlico.io/v1/sepolia/rpc?apikey=${PIMLICO_API_KEY}`,
  options: {
    safeAddress: '0x...'
  },
  // ...
})

So I managed to load the SafeKit (had to specify the custom SAFE_4337_MODULE) as follows:

export async function existingSafe(
  signer: string,
  chainId: bigint,
): Promise<string | undefined> {
  const apiKit = new SafeApiKit({
    chainId,
  });
  const safes = (await apiKit.getSafesByOwner(signer)).safes;
  const safeInfos = await Promise.all(
    safes.map((safeAddress) => apiKit.getSafeInfo(safeAddress)),
  );
  const relevantSafes = safeInfos.filter((info) => isRelevantSafe(info));
  console.log("Relevant Safes", relevantSafes)
  if (relevantSafes.length > 0) {
    if (relevantSafes.length > 1) {
      console.warn(
        `Found multiple relevant Safes for ${signer} - using the first`,
      );
    }
    return relevantSafes[0].address;
  }
}

export async function loadSafeKit(
  rpcUrl: string,
  bundlerUrl: string,
  nearSigner: string,
): Promise<Safe4337Pack> {
  const signer = sanitizeAddress(nearSigner);
  const provider = new ethers.JsonRpcProvider(rpcUrl);
  const safeAddress = await existingSafe(
    signer,
    (await provider.getNetwork()).chainId,
  );
  let options = safeAddress
    ? { safeAddress }
    : {
        owners: [signer],
        threshold: 1,
      };
  const safe4337Pack = await Safe4337Pack.init({
    provider: rpcUrl,
    bundlerUrl,
    options,
    customContracts: {
      // Why do I need to specify this?
      safe4337ModuleAddress: SAFE_4337_MODULE
    }
    // ...
  });
  return safe4337Pack;
}

So sad... I am not sure how there could have been a non-trivial signature in this UserOp. Since I did not specify a signer key

Error: could not coalesce error (
error={ 
	"code": -32521, 
	"message": "UserOperation reverted during simulation with reason: AA23 reverted (or OOG)" 
}, 
payload={ 
	"id": 4, 
	"jsonrpc": "2.0", 
	"method": "eth_estimateUserOperationGas", 
	"params": [ { 
		"callData": "0x7bb37428000000000000000000000000234cc5b8b9be39fdf0a2e0bced254ead9f4245fc00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000016900000000000000000000000000000000000000000000000000000000000000", 
		"callGasLimit": "0x01", 
		"initCode": "0x", 
		"maxFeePerGas": "0x0965c6a495", 
		"maxPriorityFeePerGas": "0x1a6576be", 
		"nonce": "0x00", 
		"paymasterAndData": "0x", 
		"preVerificationGas": "0x01", 
		"sender": "0xb5afd8E64278898191e5B54e18449252De46dE60", 
		"signature": "0x000000000000000000000000000000000000000000000000234cc5b8b9be39fdf0a2e0bced254ead9f4245fc0000000000000000000000000000000000000000000000000000000000000000010000000000000000000000007f01d9b227593e033bf8d6fc86e634d27aa85568000000000000000000000000000000000000000000000000000000000000000001", 
		"verificationGasLimit": "0x01" 
	}, "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789" ] }, code=UNKNOWN_ERROR, version=6.13.1)

So then I proceed in changing the entry point address:

Error: The selected entrypoint 0x0000000071727de22e5e9d8baf0edac6f37da032 is not compatible with version 0.2.0 of Safe modules

calculateSafeOpHash could be part of the kit

So I manage to make the safeOp, but then have to import something else and pass it a bunch of stuff from the kit:

  const safeOp = await safeKit.createTransaction({ transactions });
  
  
  const safeOpHash = calculateSafeUserOperationHash(
    safeOp.data,
    // This is all part of the kit! could have safeKit.safeOpHash(safeOp)
    BigInt(await safeKit.getChainId()),
    await safeKit.protocolKit.getFallbackHandler(),
  );

4. Can not add signature

So I have produced a signature for safeOp by signing the hash. Now my options are

safeOp.addSignature({signer, data: signature, isContractSignature: false})

for which I am expected to supply:

export interface SafeSignature {
    readonly signer: string;
    readonly data: string;
    readonly isContractSignature: boolean;
    // WTF are these?
    staticPart(dynamicOffset?: string): string;
    dynamicPart(): string;
}

Alternatively there is safeKit.signSafeOperation which also requires a signer to be present.

No way to manually provide a signature!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment