Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions packages/protocol-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,5 +89,8 @@
"optionalDependencies": {
"@noble/curves": "^1.6.0",
"@peculiar/asn1-schema": "^2.3.13"
},
"peerDependencies": {
"ethers": "^6.13.7"
}
}
69 changes: 57 additions & 12 deletions packages/protocol-kit/src/SafeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
toTransactionRequest,
sameString
} from '@safe-global/protocol-kit/utils'
import { isTypedDataSigner } from '@safe-global/protocol-kit/contracts/utils'
import { isEthersSigner } from '@safe-global/protocol-kit/contracts/utils'
import {
getSafeWebAuthnSignerFactoryContract,
getSafeWebAuthnSharedSignerContract
Expand Down Expand Up @@ -125,7 +125,7 @@ class SafeProvider {

let passkeySigner

if (!isSignerPasskeyClient(signer)) {
if (!isSignerPasskeyClient(signer) && !isEthersSigner(signer)) {
// signer is type PasskeyArgType {rawId, coordinates, customVerifierAddress? }
const safeWebAuthnSignerFactoryContract = await getSafeWebAuthnSignerFactoryContract({
safeProvider,
Expand Down Expand Up @@ -172,6 +172,60 @@ class SafeProvider {
return this.signer as PasskeyClient
}

// Check for ethers.js signer
if (isEthersSigner(this.signer)) {
// Return the ethers.js signer with a compatible interface
const ethersSignerAddress = (await (this.signer as any).getAddress?.()) || ''

// Create a minimal viem-like adapter for ethers signer
return {
account: {
address: ethersSignerAddress,
type: 'json-rpc'
},
signTypedData: async (params: any) => {
try {
// Ethers v5 style
if (typeof (this.signer as any)._signTypedData === 'function') {
return await (this.signer as any)._signTypedData(
params.domain,
params.types,
params.message
)
}
// Ethers v6 style
else if (typeof (this.signer as any).signTypedData === 'function') {
// Try object param style first
try {
return await (this.signer as any).signTypedData(params)
} catch {
// Fallback to separate params
return await (this.signer as any).signTypedData(
params.domain,
params.types,
params.message
)
}
}
} catch (error) {
console.error('Error signing typed data with ethers signer:', error)
throw error
}
},
signMessage: async (params: any) => {
const message =
typeof params.message === 'object' && params.message.raw
? new Uint8Array(params.message.raw)
: params.message

if (typeof (this.signer as any).signMessage === 'function') {
return await (this.signer as any).signMessage(message)
}
throw new Error('Ethers signer does not implement signMessage')
}
} as unknown as ExternalSigner
}

if (isPrivateKey(this.signer)) {
// This is a client with a local account, the account needs to be of type Account as Viem consider strings as 'json-rpc' (on parseAccount)
const account = privateKeyToAccount(asHex(this.signer as string))
Expand All @@ -182,15 +236,6 @@ class SafeProvider {
})
}

// If we have a signer and its not a PK, it might be a delegate on the rpc levels and this should work with eth_requestAcc
if (this.signer && typeof this.signer === 'string') {
return createWalletClient({
account: this.signer,
chain,
transport: custom(transport)
})
}

try {
// This behavior is a reproduction of JsonRpcApiProvider#getSigner (which is super of BrowserProvider).
// it dispatches and eth_accounts and picks the index 0. https://github.com/ethers-io/ethers.js/blob/a4b1d1f43fca14f2e826e3c60e0d45f5b6ef3ec4/src.ts/providers/provider-jsonrpc.ts#L1119C24-L1119C37
Expand Down Expand Up @@ -319,7 +364,7 @@ class SafeProvider {
throw new Error('SafeProvider must be initialized with a signer to use this method')
}

if (isTypedDataSigner(signer)) {
if (isEthersSigner(signer)) {
const typedData = generateTypedData(safeEIP712Args)
const { chainId, verifyingContract } = typedData.domain
const chain = chainId ? Number(chainId) : undefined // ensure empty string becomes undefined
Expand Down
6 changes: 3 additions & 3 deletions packages/protocol-kit/src/contracts/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
import { SafeVersion } from '@safe-global/types-kit'
import { DeploymentType } from '../types'

export const DEFAULT_SAFE_VERSION: SafeVersion = '1.3.0'
export const DEFAULT_SAFE_VERSION: SafeVersion = '1.4.1'
export const SAFE_BASE_VERSION: SafeVersion = '1.0.0'

type contractNames = {
Expand Down Expand Up @@ -112,8 +112,8 @@ export const safeDeploymentsVersions: SafeDeploymentsVersions = {
}
}

export const safeDeploymentsL1ChainIds = [
1n // Ethereum Mainnet
export const safeDeploymentsL1ChainIds: bigint[] = [
// Never use l1 version
]

const contractFunctions: Record<
Expand Down
5 changes: 3 additions & 2 deletions packages/protocol-kit/src/contracts/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,9 +508,10 @@ export function toTxResult(
}
}

export function isTypedDataSigner(signer: any): signer is Client {
export function isEthersSigner(signer: any): signer is Client {
const isPasskeySigner = !!signer?.passkeyRawId
return (signer as unknown as WalletClient).signTypedData !== undefined || !isPasskeySigner
// Check for both viem wallets and our ethers adapter
return typeof signer?.signTypedData === 'function' && !isPasskeySigner
}

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/relay-kit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export * from './packs/safe-4337/utils'

export * from './RelayKitBasePack'

export { GenericFeeEstimator } from './packs/safe-4337/estimators/generic/GenericFeeEstimator'

declare module 'abitype' {
export interface Register {
AddressType: string
Expand Down
165 changes: 144 additions & 21 deletions packages/relay-kit/src/packs/safe-4337/Safe4337Pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ export class Safe4337Pack extends RelayKitBasePack<{
!paymasterOptions.isSponsored &&
!!paymasterOptions.paymasterTokenAddress

if (isApproveTransactionRequired) {
if (isApproveTransactionRequired && !paymasterOptions.skipApproveTransaction) {
const { paymasterAddress, amountToApprove = MAX_ERC20_AMOUNT_TO_APPROVE } = paymasterOptions

// second transaction: approve ERC-20 paymaster token
Expand Down Expand Up @@ -400,20 +400,22 @@ export class Safe4337Pack extends RelayKitBasePack<{
*
* @param {EstimateFeeProps} props - The parameters for the gas estimation.
* @param {BaseSafeOperation} props.safeOperation - The SafeOperation to estimate the gas.
* @param {PaymasterOptions} props.paymasterOptions - The paymaster options.
* @param {IFeeEstimator} props.feeEstimator - The function to estimate the gas.
* @return {Promise<BaseSafeOperation>} The Promise object that will be resolved into the gas estimation.
*/

async getEstimateFee({
safeOperation,
paymasterOptions,
feeEstimator = new PimlicoFeeEstimator()
}: EstimateFeeProps): Promise<BaseSafeOperation> {
const threshold = await this.protocolKit.getThreshold()
const preEstimationData = await feeEstimator?.preEstimateUserOperationGas?.({
bundlerUrl: this.#BUNDLER_URL,
entryPoint: this.#ENTRYPOINT_ADDRESS,
userOperation: safeOperation.getUserOperation(),
paymasterOptions: this.#paymasterOptions
paymasterOptions
})

if (preEstimationData) {
Expand All @@ -432,6 +434,16 @@ export class Safe4337Pack extends RelayKitBasePack<{
})

if (estimateUserOperationGas) {
if (
feeEstimator.defaultVerificationGasLimitOverhead != null &&
estimateUserOperationGas.verificationGasLimit != null
) {
estimateUserOperationGas.verificationGasLimit = (
BigInt(estimateUserOperationGas.verificationGasLimit) +
BigInt(threshold) * feeEstimator.defaultVerificationGasLimitOverhead
).toString()
}

safeOperation.addEstimations(estimateUserOperationGas)
}

Expand All @@ -442,10 +454,19 @@ export class Safe4337Pack extends RelayKitBasePack<{
...safeOperation.getUserOperation(),
signature: getDummySignature(this.#SAFE_WEBAUTHN_SHARED_SIGNER_ADDRESS, threshold)
},
paymasterOptions: this.#paymasterOptions
paymasterOptions
})

if (postEstimationData) {
if (
feeEstimator.defaultVerificationGasLimitOverhead != null &&
postEstimationData.verificationGasLimit != null
) {
postEstimationData.verificationGasLimit = (
BigInt(postEstimationData.verificationGasLimit) +
BigInt(threshold) * feeEstimator.defaultVerificationGasLimitOverhead
).toString()
}
safeOperation.addEstimations(postEstimationData)
}

Expand All @@ -463,11 +484,26 @@ export class Safe4337Pack extends RelayKitBasePack<{
transactions,
options = {}
}: Safe4337CreateTransactionProps): Promise<BaseSafeOperation> {
const { amountToApprove, validUntil, validAfter, feeEstimator, customNonce } = options
const {
amountToApprove,
validUntil,
validAfter,
feeEstimator,
customNonce,
paymasterTokenAddress
} = options

const paymasterOptions: PaymasterOptions = this.#paymasterOptions
? { ...this.#paymasterOptions }
: undefined

if (paymasterOptions && !paymasterOptions.isSponsored && paymasterTokenAddress) {
paymasterOptions.paymasterTokenAddress = paymasterTokenAddress
}

const userOperation = await createUserOperation(this.protocolKit, transactions, {
entryPoint: this.#ENTRYPOINT_ADDRESS,
paymasterOptions: this.#paymasterOptions,
paymasterOptions,
amountToApprove,
customNonce
})
Expand All @@ -486,6 +522,7 @@ export class Safe4337Pack extends RelayKitBasePack<{

return await this.getEstimateFee({
safeOperation,
paymasterOptions,
feeEstimator
})
}
Expand Down Expand Up @@ -609,22 +646,61 @@ export class Safe4337Pack extends RelayKitBasePack<{

const signerAddress = signer.account.address
const safeOperation = safeOp.getSafeOperation()
const signature = await signer.signTypedData({
domain: {
chainId: Number(this.#chainId),
verifyingContract: this.#SAFE_4337_MODULE_ADDRESS
},
types: safeOp.getEIP712Type(),
message: {
...safeOperation,
nonce: BigInt(safeOperation.nonce),
validAfter: toHex(safeOperation.validAfter),
validUntil: toHex(safeOperation.validUntil),
maxFeePerGas: toHex(safeOperation.maxFeePerGas),
maxPriorityFeePerGas: toHex(safeOperation.maxPriorityFeePerGas)
},
primaryType: 'SafeOp'
})

// Prepare the parameters for signTypedData
const domain = {
chainId: Number(this.#chainId),
verifyingContract: this.#SAFE_4337_MODULE_ADDRESS
}

const types = safeOp.getEIP712Type()

const message = {
...safeOperation,
nonce: BigInt(safeOperation.nonce),
validAfter: toHex(safeOperation.validAfter),
validUntil: toHex(safeOperation.validUntil),
maxFeePerGas: toHex(safeOperation.maxFeePerGas),
maxPriorityFeePerGas: toHex(safeOperation.maxPriorityFeePerGas)
}

let signature: string

// Use a try-catch to support both viem and ethers.js signers
try {
// First try the standard viem way
signature = await signer.signTypedData({
domain,
types,
message,
primaryType: 'SafeOp'
})
} catch (error) {
// If viem fails, try ethers.js way using a type assertion
const ethersCompatibleSigner = signer as any

if (typeof ethersCompatibleSigner._signTypedData === 'function') {
// Ethers v5
signature = await ethersCompatibleSigner._signTypedData(domain, types, message)
} else if (typeof ethersCompatibleSigner.signTypedData === 'function') {
// Ethers v6 with different parameter format
try {
// Try calling with object format first (some implementations support this)
signature = await ethersCompatibleSigner.signTypedData({
domain,
types,
primaryType: 'SafeOp',
message
})
} catch {
// Fallback to ethers v6 standard format
signature = await ethersCompatibleSigner.signTypedData(domain, types, message)
}
} else {
// Re-throw if we couldn't handle it
throw error
}
}

safeSignature = new EthSafeSignature(signerAddress, signature)
} else {
Expand Down Expand Up @@ -713,6 +789,53 @@ export class Safe4337Pack extends RelayKitBasePack<{
return this.#bundlerClient.request({ method: RPC_4337_CALLS.CHAIN_ID })
}

/**
* Returns the exchange rate applied by the paymaster.
*
* @param {string} tokenAddress - The address of the token to get the exchange rate for.
* @returns {Promise<number>} - The exchange rate for the token used by the paymaster.
* @throws {Error} If paymaster URL is not configured or if the token is not supported
*/
async getTokenExchangeRate(tokenAddress: string): Promise<number> {
if (!this.#paymasterOptions?.paymasterUrl) {
throw new Error('Paymaster URL is not configured')
}

const bundlerClient = createBundlerClient(this.#paymasterOptions?.paymasterUrl)
const isPimlico = this.#paymasterOptions.paymasterUrl.includes('pimlico')

if (isPimlico) {
const response = await bundlerClient.request({
method: 'pimlico_getTokenQuotes',
params: [
{
tokens: [tokenAddress]
},
this.#ENTRYPOINT_ADDRESS,
'0x1'
]
})

return parseInt(response.quotes[0].exchangeRate, 16)
} else {
const response = await bundlerClient.request({
method: 'pm_supportedERC20Tokens',
params: [this.#ENTRYPOINT_ADDRESS]
})

const matchingToken = response.tokens.find(
(token: { address: string; exchangeRate: string }) =>
token.address.toLowerCase() === tokenAddress.toLowerCase()
)

if (!matchingToken) {
throw new Error(`No exchange rate found for token: ${tokenAddress}`)
}

return parseInt(matchingToken.exchangeRate, 16)
}
}

getOnchainIdentifier(): string {
return this.#onchainIdentifier
}
Expand Down
Loading
Loading